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:
Turtle
2026-07-22 15:01:54 +08:00
parent f1f35ccaef
commit 2a9f248594
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 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。
@@ -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
@@ -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.
@@ -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
View File
@@ -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
+3
View File
@@ -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` |
+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-3
View File
@@ -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,
}, {
-1
View File
@@ -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
+2 -81
View File
@@ -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
+1 -223
View File
@@ -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')
})
})