feat(tui): auto-title the terminal pane from the first user message

This commit is contained in:
Turtle
2026-07-22 10:55:18 +08:00
parent 9c8994f65a
commit ea93166e57
6 changed files with 118 additions and 7 deletions

View File

@@ -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-21-tui-auto-pane-title.md: 5efa86ae983d276964babad120f3e4131bc20b0d
2026-07-21-tui-auto-pane-title.zh.md: 3bd1dad8af1bfaf14669e9f0d3df13a4d3c12159

View File

@@ -0,0 +1,37 @@
# Agent Note: Auto-titled terminal from the first message
Status: implemented
English | [中文](2026-07-21-tui-auto-pane-title.zh.md)
## Problem
The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing.
## Decision
- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback.
- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis).
- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes.
- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown.
- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop.
- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message.
## Alternatives considered
**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title.
**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever.
**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert.
**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency.
**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn.
## Consequences
- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing.
- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay.
- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted.
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call.

View File

@@ -0,0 +1,37 @@
# Agent Note: 从首条消息自动命名终端
Status: implemented
[English](2026-07-21-tui-auto-pane-title.md) | 中文
## Problem
TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent智能体的用户看来它们的标签全都一样因此窗格一眼看去无从区分标签栏也不携带任何关于各会话正在做什么的信号。
## Decision
- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`。开启后TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。
- 该标签是模型概括而非对提示词的截断。请求携带一段固定的任务指令将该请求概括为两到五个小写单词、不含标点的简短标题加上用户的首条消息且不带工具TUI 取回复的首个非空行并截断到 40 个字符39 个字符加一个省略号)。
- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。
- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider``model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。
- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript文本记录也无法扰动 agent loop智能体循环
- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture测试前置数据中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。
## Alternatives considered
**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。
**重命名窗口OSC 2或 tmux 窗口。** 否决OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。
**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。
**并入日志支撑的会话标题工作PR #451** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。
**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。
## Consequences
- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token默认关闭时它不产生任何开销。
- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。
- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息因此它们从不触发该调用。

View File

@@ -1728,12 +1728,10 @@ export interface Config {
maxSourceBytes?: number maxSourceBytes?: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
instructionFileCandidates?: string[] instructionFileCandidates?: string[]
/** Ordered same-directory local-overlay candidates loaded in addition to the base file per scope; empty disables the overlay. */
localInstructionFileCandidates?: string[]
} }
``` ```
Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts) Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/context/workspace-context/src/config.ts)
## Loadable plugins with no config ## Loadable plugins with no config

View File

@@ -5,6 +5,14 @@ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1'
const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}`
const FINAL_TEXT = 'Decision received. Scripted TUI run complete.' const FINAL_TEXT = 'Decision received. Scripted TUI run complete.'
// The `skill` scenario types `/skill:scripted-skill`; the manual-invocation front
// door delivers the loaded skill as a user turn wrapped in `<skill name="…">`. The
// body marker below lives in the fixture skill, so echoing it back proves the whole
// block (name attribute plus body) reached the model, not just the command text.
const SKILL_BLOCK_OPEN = '<skill name="scripted-skill">'
const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER'
const SKILL_RECEIVED_TEXT = 'Scripted skill body received.'
const TITLE_TEXT = 'scripted session title'
function textChunks(text: string): StreamChunk[] { function textChunks(text: string): StreamChunk[] {
return [ return [
@@ -16,7 +24,7 @@ function textChunks(text: string): StreamChunk[] {
] ]
} }
/** Keyless two-step adapter for the real-PTY TUI conversation test. */ /** Keyless adapter for the real-PTY TUI tests: the two-step conversation and the `/skill:` round-trip. */
class ScriptedTuiAdapter extends LlmAdapter { class ScriptedTuiAdapter extends LlmAdapter {
override listModels(provider: string): Promise<readonly LlmModelInfo[]> { override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([ return Promise.resolve([
@@ -30,10 +38,29 @@ class ScriptedTuiAdapter extends LlmAdapter {
} }
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { 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.
if ((options.tools?.length ?? 0) === 0) {
for (const chunk of textChunks(TITLE_TEXT)) yield chunk
return
}
if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) {
throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables')
} }
const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false const lastMessage = options.messages.at(-1)
const lastText = (lastMessage?.content ?? [])
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
if (lastText.includes(SKILL_BLOCK_OPEN)) {
const ack = lastText.includes(SKILL_BODY_MARKER)
? SKILL_RECEIVED_TEXT
: 'Scripted skill block arrived without its body.'
for (const chunk of textChunks(ack)) yield chunk
return
}
const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false
if (hasToolResult) { if (hasToolResult) {
for (const chunk of textChunks(FINAL_TEXT)) yield chunk for (const chunk of textChunks(FINAL_TEXT)) yield chunk
return return

View File

@@ -8,7 +8,7 @@ import AgentRegistry, {
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands' import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -31,6 +31,7 @@ export interface TuiHarnessOptions {
beforeMount?: (session: Session) => void beforeMount?: (session: Session) => void
cwd?: string | null cwd?: string | null
formatCwd?: TuiRuntime['formatCwd'] formatCwd?: TuiRuntime['formatCwd']
/** Fake-agent creation options; auto-title resolves its target from `provider`/`model`. */
agentOptions?: AgentOptions agentOptions?: AgentOptions
contextWindow?: number contextWindow?: number
contextTokens?: number contextTokens?: number
@@ -41,6 +42,8 @@ export interface TuiHarnessOptions {
listModels?: (provider: string) => Promise<LlmModelInfo[]> listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined> resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
} }
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: { list(): Promise<SessionHeader[]> }
} }
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> { export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -105,6 +108,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await options.configureContext(ctx) await options.configureContext(ctx)
} }
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt) if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
if (options.sessionPersistence !== undefined) {
ctx.provide('sessionPersistence', options.sessionPersistence as never)
}
const sessionId = SessionId('main-session') const sessionId = SessionId('main-session')
const session = ctx.sessions.create( const session = ctx.sessions.create(
sessionId, sessionId,
@@ -176,7 +182,7 @@ export function appendUser(session: Session, text: string): void {
export function appendAssistant( export function appendAssistant(
session: Session, session: Session,
content: ContentBlock[], content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number }, usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
position: { turn: number; step: number } = { turn: 1, step: 1 }, position: { turn: number; step: number } = { turn: 1, step: 1 },
): void { ): void {
session.append('assistant/message', { session.append('assistant/message', {