Explorar el Código

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

Turtle hace 2 meses
padre
commit
ea93166e57

+ 6 - 0
.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.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-21-tui-auto-pane-title.md: 5efa86ae983d276964babad120f3e4131bc20b0d
+2026-07-21-tui-auto-pane-title.zh.md: 3bd1dad8af1bfaf14669e9f0d3df13a4d3c12159

+ 37 - 0
.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md

@@ -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.

+ 37 - 0
.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md

@@ -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。启动场景不发送用户消息,因此它们从不触发该调用。

+ 1 - 3
docs/config-catalog.md

@@ -1728,12 +1728,10 @@ export interface Config {
   maxSourceBytes?: number
   /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
   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
 

+ 29 - 2
examples/tui-agent/tests/fixtures/tui-scripted-llm.ts

@@ -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 INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}`
 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[] {
   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 {
   override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
     return Promise.resolve([
@@ -30,10 +38,29 @@ 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.
+    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')) {
       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) {
       for (const chunk of textChunks(FINAL_TEXT)) yield chunk
       return

+ 8 - 2
packages/ui/tui/tests/harness.ts

@@ -8,7 +8,7 @@ import AgentRegistry, {
 } from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
 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 type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -31,6 +31,7 @@ export interface TuiHarnessOptions {
   beforeMount?: (session: Session) => void
   cwd?: string | null
   formatCwd?: TuiRuntime['formatCwd']
+  /** Fake-agent creation options; auto-title resolves its target from `provider`/`model`. */
   agentOptions?: AgentOptions
   contextWindow?: number
   contextTokens?: number
@@ -41,6 +42,8 @@ export interface TuiHarnessOptions {
     listModels?: (provider: string) => Promise<LlmModelInfo[]>
     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> {
@@ -105,6 +108,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
     await options.configureContext(ctx)
   }
   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 session = ctx.sessions.create(
     sessionId,
@@ -176,7 +182,7 @@ export function appendUser(session: Session, text: string): void {
 export function appendAssistant(
   session: Session,
   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 },
 ): void {
   session.append('assistant/message', {