refactor(tui): drop the TUI-local auto-title; titles come from the session-title service
Master's log-backed session-title capability already titles sessions durably (deterministic fallback in the spine, optional model providers). Remove the TUI's own autoTitle generation — the latch, prompt, cap, and llm stream call — and keep the terminal rename: the TUI folds the logged title on mount and sets '<session title> — <configured title>' on every accepted session/title event. The tui-agent example and the scripted PTY fixture mount session-title-first-message-llm so titles stay model-made; the scripted adapter's tool-less branch now answers that provider's auxiliary request. See .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md
This commit is contained in:
19 files changed
+144
-327
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-pane-title.md: 354602cac465b20157af563ccc32acf82cc54e73
|
||||
2026-07-21-tui-auto-pane-title.zh.md: 97178d723b92b243f3bb1b58aa86b681eb9e8573
|
||||
2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08
|
||||
2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-pane-title.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-pane-title.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-title-default-on.md: 1121cf39c79b01dc5e45ff31fccbe82ccee6d101
|
||||
2026-07-21-tui-auto-title-default-on.zh.md: 5569c9d71b7481bcf74e39320a52f1f47e7161ed
|
||||
2026-07-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22
|
||||
2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-title-default-on.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
## Problem
|
||||
|
||||
The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience.
|
||||
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-title-default-on.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
## Problem
|
||||
|
||||
[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-tui-titles-from-session-title-service.md: b54b99647230255cf241415f94aa21b2630c44cd
|
||||
2026-07-22-tui-titles-from-session-title-service.zh.md: 67cc3332f0694887d5af0d71997d140b74669f46
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: TUI titles come from the session-title service
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two model-title implementations coexisted after the tui-staging line merged onto master. The TUI carried its own `autoTitle` feature: a fire-and-forget `ctx.llm.stream` call after the first user message that set the terminal window title via OSC 0, with a one-shot latch, its own prompt, its own 40-character cap, and its own resume re-derivation ([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md), [default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md)). Master had meanwhile landed [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md): a `sessionTitle` capability whose accepted revisions are durable `session/title` events, with a deterministic fallback and optional model providers. The TUI already consumed `session/title` for its header subtitle and window title, so a session could be titled twice by different strategies, and the TUI's process-local title was invisible to every other consumer (ACP, resume listings, forks).
|
||||
|
||||
## Decision
|
||||
|
||||
The TUI-local generation is removed; the session-title service is the one title source. `TuiConfig.autoTitle`, the latch, the abort controller, the title prompt, and `titleLine` are gone from `dsh-tui`. The terminal rename stays: the TUI folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and sets the terminal window title to `<session title> — <configured title>` on every accepted `session/title` event — including resumed sessions, whose titles now replay from the log instead of being re-generated.
|
||||
|
||||
Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and the scripted PTY fixture) mount `@deepseek-ai/dsh-session-title-first-message-llm`, which inherits the main request's route and replaces the spine's deterministic fallback with a short model summary. Deployments without the provider keep the fallback title from `dsh-agent-spine-demo`'s bundled `SessionTitleService`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep both, letting the logged title win.** This was the first merge resolution: auto-title owned the whole window title until a logged `session/title` arrived in suffix form. It preserved behavior but doubled the model calls on every fresh session and left the TUI's title unobservable in the log, violating model-visible ⟺ logged in spirit and splitting the title contract across two owners.
|
||||
|
||||
**Port auto-title's prompt and cap into the service as a third provider.** The first-message-llm provider already exists with the same cadence, a reviewed prompt contract, durable request records, and supersession fencing; a second near-identical provider would be pure duplication.
|
||||
|
||||
## Consequences
|
||||
|
||||
One title pipeline: durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI sheds ~90 lines and its `llm`-streaming path. The cost is that a title now requires the provider plugin in the composition for model quality — a leaf choice, not a TUI default — and the terminal title changes shape from the bare model summary to the suffixed `<title> — <product>` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: TUI 标题来自 session-title 服务
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-tui-titles-from-session-title-service.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自带 `autoTitle` 特性:在首条用户消息后发起一次 fire-and-forget 的 `ctx.llm.stream` 调用,通过 OSC 0 设置终端窗口标题,带有一次性闩锁、自己的提示词、自己的 40 字符截断和自己的恢复重推导([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md)、[default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md))。而 master 已落地[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md):一个 `sessionTitle` 能力,其被接受的修订是持久的 `session/title` 事件,带确定性回退和可选的模型 provider。TUI 已经消费 `session/title` 作为横幅副标题和窗口标题,于是一个会话可能被两种策略各标题一次,且 TUI 的进程本地标题对其他所有消费者(ACP、恢复列表、fork)不可见。
|
||||
|
||||
## 决策
|
||||
|
||||
移除 TUI 本地生成;session-title 服务是唯一的标题来源。`TuiConfig.autoTitle`、闩锁、abort controller、标题提示词和 `titleLine` 全部从 `dsh-tui` 删除。终端重命名保留:TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上把终端窗口标题设为 `<会话标题> — <配置标题>` —— 包括恢复的会话,其标题现在从日志回放而不是重新生成。
|
||||
|
||||
模型生成的标题是组合选择:`examples/tui-agent/cordis.yml`(以及脚本化 PTY fixture)挂载 `@deepseek-ai/dsh-session-title-first-message-llm`,它继承主请求的确切路由,用简短的模型摘要替换 spine 的确定性回退。未挂载该 provider 的部署保留 `dsh-agent-spine-demo` 内置 `SessionTitleService` 的回退标题。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**两者并存,已记录标题胜出。** 这是第一版合并决议:auto-title 独占整个窗口标题,直到已记录的 `session/title` 以后缀形式到达。它保留了行为,但每个新会话产生双倍模型调用,且 TUI 的标题在日志中不可观察,实质上违反 model-visible ⟺ logged,并把标题契约拆给两个所有者。
|
||||
|
||||
**把 auto-title 的提示词和截断移植为服务的第三个 provider。** first-message-llm provider 已经存在,节奏相同,且有经过评审的提示词契约、持久的请求记录和替换围栏;再造一个近乎相同的 provider 纯属重复。
|
||||
|
||||
## 影响
|
||||
|
||||
标题管线归一:持久、可回放、对所有消费者可见,并由服务对过期完成设防。TUI 削减约 90 行及其 `llm` 流式路径。代价是模型质量的标题现在需要在组合中挂载 provider 插件 —— 这是叶配置选择,不是 TUI 默认值 —— 且终端标题形状从裸模型摘要变为日志路径一贯使用的 `<标题> — <产品>` 后缀形式。被取代的 auto-title Agent Note 携带指向本文的指针。
|
||||
+37
-7
@@ -1460,10 +1460,18 @@ Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `sy
|
||||
```ts config-catalog
|
||||
/** Serializable plugin configuration. */
|
||||
export interface Config extends TuiConfig {
|
||||
/** Header subtitle. Defaults to `ready.`. */
|
||||
/** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
|
||||
welcome?: string
|
||||
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
|
||||
sessionId?: string
|
||||
/**
|
||||
* Shell command template shown for resuming this session: printed on exit and
|
||||
* listed by `/resume`, with every `{session}` occurrence replaced by the live
|
||||
* session id. Absent disables both surfaces. Deployments set it only when a
|
||||
* persistence backend makes the session resumable (e.g.
|
||||
* `RESUME_SESSION_ID={session} dsh`).
|
||||
*/
|
||||
resumeCommand?: string
|
||||
}
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
@@ -1488,12 +1496,19 @@ export interface TuiConfig {
|
||||
showHardwareCursor?: boolean
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
color?: boolean
|
||||
/** Terminal window title while the UI is mounted. */
|
||||
/**
|
||||
* Paint the startup banner's product name in the DeepSeek brand gradient
|
||||
* using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the
|
||||
* flat accent color when either is off. Unset auto-detects `COLORTERM` at the
|
||||
* process boundary, so most deployments leave it unset.
|
||||
*/
|
||||
truecolor?: boolean
|
||||
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
|
||||
title?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:129`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:145`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
@@ -1520,8 +1535,15 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** TUI subtitle rendered on start. Defaults to `ready.`. */
|
||||
/** TUI transcript's optional first line; absent renders nothing on start. */
|
||||
welcome?: string
|
||||
/**
|
||||
* Shell command template the TUI prints on exit and lists under `/resume`,
|
||||
* with `{session}` replaced by the live session id (forwarded to the front
|
||||
* door). Set it to a command that resumes via this app's env var, e.g.
|
||||
* `RESUME_SESSION_ID={session} dsh`.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
/** Full-screen TUI presentation settings. */
|
||||
ui?: uiTui.TuiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
@@ -1541,7 +1563,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts)
|
||||
Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
@@ -1726,12 +1748,20 @@ export interface Config {
|
||||
maxBytes: number
|
||||
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
|
||||
maxSourceBytes?: number
|
||||
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
|
||||
/**
|
||||
* Ordered same-directory project candidates; every existing file loads, with
|
||||
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
|
||||
*/
|
||||
instructionFileCandidates?: string[]
|
||||
/**
|
||||
* Ordered same-directory local-overlay candidates loaded after the base files
|
||||
* under the same per-directory trimmed-content dedup; empty disables the overlay.
|
||||
*/
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/context/workspace-context/src/config.ts)
|
||||
Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts)
|
||||
|
||||
## Loadable plugins with no config
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ flowchart LR
|
||||
bundle_agent_core --> spine_sessions["ctx.sessions"]
|
||||
bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
|
||||
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
|
||||
plugin_tui_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"]
|
||||
cfg --> plugin_tui_session_title_llm
|
||||
plugin_tui_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
|
||||
cfg --> plugin_tui_token_meter
|
||||
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
|
||||
@@ -67,6 +69,7 @@ flowchart LR
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
| `bash` | `@deepseek-ai/dsh-bash-local` |
|
||||
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
|
||||
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
|
||||
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
|
||||
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
|
||||
|
||||
@@ -45,8 +45,6 @@
|
||||
ui:
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
# Rename the terminal after the first message with a short model-made title.
|
||||
autoTitle: true
|
||||
# Keep the persona to identity and behavior; tool plugins own tool guidance.
|
||||
# The loop resolves {{model}} from this agent's configuration.
|
||||
persona: |
|
||||
@@ -55,6 +53,19 @@
|
||||
Verify your work by running the code or tests. Keep answers brief and
|
||||
factual.
|
||||
|
||||
# Model-made session titles on the first-message cadence: replaces the spine's
|
||||
# deterministic fallback title with a short model summary. The TUI renders the
|
||||
# logged `session/title` as the banner subtitle and the terminal window title.
|
||||
# Omitting provider/model inherits the main request's exact route.
|
||||
- id: session-title-llm
|
||||
name: '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
config:
|
||||
targetWords: 5
|
||||
targetCjkCharacters: 10
|
||||
maxInputBytes: 4096
|
||||
maxOutputTokens: 64
|
||||
timeoutMs: 60000
|
||||
|
||||
# Replay-aware request pressure with one service-wide context window.
|
||||
- id: token-meter
|
||||
name: '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
+3
-2
@@ -38,8 +38,9 @@ class ScriptedTuiAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// The TUI's auto-title request carries no tool schemas, unlike every agent
|
||||
// turn; answer it with a fixed title so the PTY test can assert the OSC set.
|
||||
// The session-title provider's auxiliary request carries no tool schemas,
|
||||
// unlike every agent turn; answer it with a fixed title so the PTY test can
|
||||
// assert the logged title reaches the terminal window title.
|
||||
if ((options.tools?.length ?? 0) === 0) {
|
||||
for (const chunk of textChunks(TITLE_TEXT)) yield chunk
|
||||
return
|
||||
|
||||
+12
-1
@@ -30,4 +30,15 @@
|
||||
persona: 'Scripted model {{model}}.'
|
||||
ui:
|
||||
showReasoning: true
|
||||
autoTitle: true
|
||||
|
||||
# Model-made session titles, as in the shipped cordis.yml: the scripted adapter
|
||||
# answers the tool-less title request with a fixed string so the PTY test can
|
||||
# assert the logged title reaches the terminal window title.
|
||||
- id: session-title-llm
|
||||
name: '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
config:
|
||||
targetWords: 5
|
||||
targetCjkCharacters: 10
|
||||
maxInputBytes: 4096
|
||||
maxOutputTokens: 64
|
||||
timeoutMs: 10000
|
||||
@@ -256,9 +256,11 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('How should the scripted run proceed?')
|
||||
expect(output).toContain('Safe')
|
||||
expect(output).toContain('Decision received. Scripted TUI run complete.')
|
||||
// Auto-title: the first user message drives a tool-less title call that the
|
||||
// scripted adapter answers, and the TUI sets it via OSC 0.
|
||||
expect(output).toContain('\u001B]0;scripted session title\u0007')
|
||||
// Session title: the first user message drives the first-message-llm
|
||||
// provider's tool-less title call; the scripted adapter answers it, the
|
||||
// accepted title lands in the log, and the TUI renders the terminal window
|
||||
// title as `<session title> — <configured title>` via OSC 0.
|
||||
expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
|
||||
@@ -268,9 +268,6 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
color: true,
|
||||
showReasoning: true,
|
||||
title: 'DSH TUI snapshot',
|
||||
// Off so replay stays deterministic: an auto-title request is not among the
|
||||
// recorded turns. Auto-title's PTY coverage lives in the keyless smoke.
|
||||
autoTitle: false,
|
||||
welcome: `Recorded replay: ${scenario.name}`,
|
||||
maxToolOutputLines: 8,
|
||||
}, {
|
||||
|
||||
@@ -42,7 +42,6 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
|
||||
| `autoTitle` | `true` | Replace `title` with a short model-made title derived from the session's first user message; a resumed session re-derives it from that stored message on mount (needs an `llm` service and an agent provider/model) |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
|
||||
@@ -46,10 +46,9 @@ import {
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { assertNever, BlockAssembler, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
@@ -108,15 +107,8 @@ export interface TuiConfig {
|
||||
* process boundary, so most deployments leave it unset.
|
||||
*/
|
||||
truecolor?: boolean
|
||||
/** Terminal window title while the UI is mounted. */
|
||||
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
|
||||
title?: string
|
||||
/**
|
||||
* Replace {@link TuiConfig.title} with a short model-generated title derived
|
||||
* from the session's first user message; a resumed session re-derives it from
|
||||
* that stored message on mount. No-op without an `llm` service or an agent
|
||||
* provider/model. On by default.
|
||||
*/
|
||||
autoTitle?: boolean
|
||||
}
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
@@ -132,7 +124,6 @@ const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
const autoTitleSchema = z.boolean().default(true)
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
@@ -148,7 +139,6 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
autoTitle: autoTitleSchema,
|
||||
})
|
||||
|
||||
/** Serializable plugin configuration. */
|
||||
@@ -183,7 +173,6 @@ export const Config: z<Config> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
autoTitle: autoTitleSchema,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
@@ -200,7 +189,6 @@ export interface ResolvedTuiConfig {
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
title: string
|
||||
autoTitle: boolean
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
@@ -239,7 +227,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
color: config?.color ?? true,
|
||||
truecolor: config?.truecolor ?? false,
|
||||
title: config?.title ?? 'DeepSeek Harness',
|
||||
autoTitle: config?.autoTitle ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,24 +413,6 @@ function contentText(content: readonly ContentBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** Longest auto-generated title kept before the tail is elided; fits common tmux/tab widths. */
|
||||
const AUTO_TITLE_MAX_LENGTH = 40
|
||||
|
||||
/** Task framing for the auto-title model call; written from the model's view, not the UI's. */
|
||||
const AUTO_TITLE_SYSTEM_PROMPT = [
|
||||
"Summarize the user's request as a short title of 2 to 5 lowercase words.",
|
||||
'Use no punctuation or quotation marks. Reply with only the title.',
|
||||
].join('\n')
|
||||
|
||||
/** First non-empty line of the model's reply, trimmed and capped for a terminal title. */
|
||||
function titleLine(text: string): string {
|
||||
const line = text.split('\n').map(part => part.trim()).find(part => part.length > 0) ?? ''
|
||||
return line.length > AUTO_TITLE_MAX_LENGTH ? `${line.slice(0, AUTO_TITLE_MAX_LENGTH - 1)}…` : line
|
||||
}
|
||||
|
||||
/** Auto-title is best-effort: a stream error or shutdown abort leaves the current title unchanged. */
|
||||
const ignoreTitleFailure = (): void => {}
|
||||
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
return content
|
||||
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
|
||||
@@ -1351,14 +1320,6 @@ export function createTuiChat(
|
||||
const skills = ctx.get('skills')
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const skillAbort = new AbortController()
|
||||
// Auto-title replaces the static title with a short model-generated title
|
||||
// derived from the session's first user message. A resumed session re-derives
|
||||
// it from that stored message on mount (see below); a fresh session derives it
|
||||
// when the first message arrives. It is already settled — keeping the static
|
||||
// title — only when the feature is off. The abort cancels an in-flight title
|
||||
// stream at shutdown.
|
||||
const titleAbort = new AbortController()
|
||||
let titleSettled = !resolved.autoTitle
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
const allToolCards = new Set<ToolCardComponent>()
|
||||
@@ -1534,44 +1495,6 @@ export function createTuiChat(
|
||||
})
|
||||
}
|
||||
|
||||
// Fire-and-forget a title request. The prompt is the trimmed first-message
|
||||
// text; an empty one is skipped without consuming the one-shot slot. The `llm`
|
||||
// service is optional, so a deployment without it (or without an agent
|
||||
// provider/model) silently keeps the static title.
|
||||
const generateTitle = (prompt: string): void => {
|
||||
if (titleSettled || prompt.length === 0) return
|
||||
titleSettled = true
|
||||
const llm = ctx.get('llm')
|
||||
const { provider, model } = agent.options
|
||||
if (llm === undefined || !provider || !model) return
|
||||
const options: GenerateOptions = {
|
||||
provider,
|
||||
model,
|
||||
system: AUTO_TITLE_SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
sessionId: agent.session.id,
|
||||
signal: titleAbort.signal,
|
||||
}
|
||||
const applyTitle = async (): Promise<void> => {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
||||
const title = titleLine(contentText(assembler.message().content))
|
||||
// Unlike a logged `session/title` (which suffixes the product title), the
|
||||
// process-local auto-title owns the whole terminal title. A logged title
|
||||
// arriving later still wins through `updateTerminalTitle`.
|
||||
if (!disposed && title.length > 0) runtime.terminal.setTitle(displayText(title))
|
||||
}
|
||||
void applyTitle().catch(ignoreTitleFailure)
|
||||
}
|
||||
|
||||
// Resume: derive the title from the session's already-logged first user
|
||||
// message. A fresh session has none here and titles from the live message via
|
||||
// the session-event listener instead.
|
||||
const firstUserMessage = agent.session.events.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message',
|
||||
)
|
||||
if (firstUserMessage !== undefined) generateTitle(contentText(firstUserMessage.data.content).trim())
|
||||
|
||||
const clearStatus = (): void => {
|
||||
if (runningStatus !== undefined) {
|
||||
clearInterval(runningStatus.timer)
|
||||
@@ -1929,7 +1852,6 @@ export function createTuiChat(
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
contextResolution = undefined
|
||||
titleAbort.abort()
|
||||
clearStatus()
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
@@ -2318,7 +2240,6 @@ export function createTuiChat(
|
||||
if (session !== agent.session) return
|
||||
recordEventUsage(tokens, event)
|
||||
advanceTurnPhase(event)
|
||||
if (event.type === 'user/message') generateTitle(contentText(event.data.content).trim())
|
||||
if (event.type === 'steering/message' && pendingSteering > 0) {
|
||||
// A queued steering message reached the model as it drained; drop it from
|
||||
// the badge. Clamped because loop-authored steering (e.g. continuation
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
@@ -148,7 +148,6 @@ describe('TUI config', () => {
|
||||
color: true,
|
||||
truecolor: false,
|
||||
title: 'DeepSeek Harness',
|
||||
autoTitle: true,
|
||||
})
|
||||
expect(resolveTuiConfig({
|
||||
showReasoning: false,
|
||||
@@ -163,7 +162,6 @@ describe('TUI config', () => {
|
||||
color: false,
|
||||
truecolor: true,
|
||||
title: 'DSH',
|
||||
autoTitle: false,
|
||||
})).toEqual({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
@@ -177,7 +175,6 @@ describe('TUI config', () => {
|
||||
color: false,
|
||||
truecolor: true,
|
||||
title: 'DSH',
|
||||
autoTitle: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2148,222 +2145,3 @@ describe('banner sweep reveal', () => {
|
||||
expect(result.terminal.output.length).toBe(settled)
|
||||
})
|
||||
})
|
||||
|
||||
/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */
|
||||
class TitleAdapter extends LlmAdapter {
|
||||
lastOptions: GenerateOptions | undefined
|
||||
calls = 0
|
||||
constructor(private readonly reply: string | Error) {
|
||||
super()
|
||||
}
|
||||
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.calls += 1
|
||||
this.lastOptions = options
|
||||
if (this.reply instanceof Error) throw this.reply
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: this.reply }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */
|
||||
function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise<void> {
|
||||
return async (ctx: Context) => {
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
}
|
||||
}
|
||||
|
||||
describe('TUI auto-title', () => {
|
||||
const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' }
|
||||
|
||||
it('replaces the title with a model-generated title after the first user message', async () => {
|
||||
const adapter = new TitleAdapter('fix the login redirect')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'the login page throws a 500 on submit, please investigate')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('fix the login redirect')
|
||||
// The request carries the task framing, the user's first message, and no tools.
|
||||
expect(adapter.lastOptions?.provider).toBe('mock')
|
||||
expect(adapter.lastOptions?.model).toBe('mock-model')
|
||||
expect(adapter.lastOptions?.system).toContain('short title')
|
||||
expect(adapter.lastOptions?.tools).toBeUndefined()
|
||||
expect(adapter.lastOptions?.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] },
|
||||
])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('requests a title only once, even after later user messages', async () => {
|
||||
const adapter = new TitleAdapter('the settled title')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'the first request that earns the title')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('the settled title')
|
||||
appendUser(result.session, 'a second request that must not re-title')
|
||||
await tick()
|
||||
expect(adapter.calls).toBe(1)
|
||||
expect(result.terminal.title).toBe('the settled title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => {
|
||||
const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'do the big thing')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('this title is deliberately far too long…')
|
||||
expect(result.terminal.title.length).toBe(40)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('skips a whitespace-only first message without consuming the one-shot slot', async () => {
|
||||
const adapter = new TitleAdapter('the real title')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, ' ')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
appendUser(result.session, 'the first real request')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('the real title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('leaves the title unchanged when the model returns no usable text', async () => {
|
||||
const adapter = new TitleAdapter(' \n ')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'anything at all')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('leaves the title unchanged when the title request fails', async () => {
|
||||
const adapter = new TitleAdapter(new Error('router unavailable'))
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'trigger a failing title request')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('re-derives the title on resume from the already-logged first user message', async () => {
|
||||
const adapter = new TitleAdapter('resumed session title')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions,
|
||||
configureContext: withLlm(adapter),
|
||||
beforeMount: (session) => {
|
||||
appendUser(session, 'the original first request')
|
||||
appendUser(session, 'a later request that must not seed the title')
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// The title comes from the stored first message, not any later one.
|
||||
expect(adapter.lastOptions?.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'the original first request' }] },
|
||||
])
|
||||
expect(result.terminal.title).toBe('resumed session title')
|
||||
// A message that arrives after the resume must not re-title.
|
||||
appendUser(result.session, 'a follow-up message')
|
||||
await tick()
|
||||
expect(adapter.calls).toBe(1)
|
||||
expect(result.terminal.title).toBe('resumed session title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when auto-title is disabled', async () => {
|
||||
const adapter = new TitleAdapter('should not run')
|
||||
const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'a normal message with the feature off')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when no llm service is available', async () => {
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions })
|
||||
appendUser(result.session, 'no model can answer this')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when the agent has no provider', async () => {
|
||||
const adapter = new TitleAdapter('unused')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions: { model: 'mock-model' },
|
||||
configureContext: withLlm(adapter),
|
||||
})
|
||||
appendUser(result.session, 'the provider is missing')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when the agent has no model', async () => {
|
||||
const adapter = new TitleAdapter('unused')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions: { provider: 'mock' },
|
||||
configureContext: withLlm(adapter),
|
||||
})
|
||||
appendUser(result.session, 'the model is missing')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('aborts an in-flight title request on shutdown', async () => {
|
||||
const seen: { aborted: boolean } = { aborted: false }
|
||||
class HangingAdapter extends LlmAdapter {
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
seen.aborted = true
|
||||
reject(new Error('aborted'))
|
||||
})
|
||||
})
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) })
|
||||
appendUser(result.session, 'start a title request that never resolves')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
expect(seen.aborted).toBe(true)
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('does not set the title when the UI is torn down before the stream completes', async () => {
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
// Yields a full reply, then blocks on the gate so the post-stream title
|
||||
// apply runs only after the test has torn the UI down. Ignores `signal`,
|
||||
// so shutdown's abort cannot cut the stream short.
|
||||
async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'title that arrives too late' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
await gate
|
||||
}
|
||||
}
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) })
|
||||
appendUser(result.session, 'start a title that finishes after teardown')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
release()
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user