diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml index 25e8aa06b6..737a9da6ca 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md index 354602cac4..069fd33a88 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md index 97178d723b..580f36b256 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml index 2137e9d594..830ca3e2e0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md index 1121cf39c7..35809e1ef6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md index 5569c9d71b..aa20cfde13 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md @@ -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 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml new file mode 100644 index 0000000000..72e2341385 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md new file mode 100644 index 0000000000..b54b996472 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md @@ -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 `` 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 ` — <product>` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here. diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md new file mode 100644 index 0000000000..67cc3332f0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md @@ -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 携带指向本文的指针。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 496c254ef3..db5254d0c7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 2f1b3201c9..b380e7610c 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -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` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index efbf67307d..e7a1c8877a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -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' diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 72253b52aa..b90f69f47e 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -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 diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 6bed7fa57e..cfc7a8e344 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -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 diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 15ccba479a..c069e72ae9 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -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) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 41d8d8160e..e0272d8a71 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -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, }, { diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index d11712367f..bc6521d058 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f56803adec..77857c0d71 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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 diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4598da4d9a..10be7f99bf 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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') - }) -})