From e55a904e1d0b534a133cd998cc0918720fff0f5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 20:26:33 +0800 Subject: [PATCH 01/14] feat(web): render remote Markdown images --- ...026-07-23-web-assistant-markdown.i18n.yaml | 6 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- ...07-30-web-remote-markdown-images.i18n.yaml | 6 + .../2026-07-30-web-remote-markdown-images.md | 29 +++ ...026-07-30-web-remote-markdown-images.zh.md | 29 +++ apps/web/tests/markdown-images.e2e.ts | 205 ++++++++++++++++++ .../snapshots/markdown-images/ui.expected.md | 33 +++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 3 +- .../src/markdown/MarkdownText.module.css | 11 + .../src/markdown/MarkdownText.tsx | 27 ++- .../ui-primitives/tests/markdown.spec.tsx | 32 ++- tsconfig.host.json | 1 + 16 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md create mode 100644 apps/web/tests/markdown-images.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-images/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -1,6 +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-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 +2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 38d193271d..d5074e6090 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. -**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index be3cd041c6..31f0fd6835 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.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 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_DONE', + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..cd33a09eb9 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..ce1f2105af 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts" ], diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..e24142ebcf 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: 929d2edd9ddbd4610f84dd901486f74700cbf4e2 +README.zh.md: 5c6df2ce58e274a53b7792597259749f9a45d4ca diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..929d2edd9d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..5c6df2ce58 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,8 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不携带 referrer 的情况下渲染绝对 HTTP(S) 图片;相对路径、绝对本地路径、`file:` URL 与不支持的协议仍保留其替代文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 + ## 终端输出 `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index a189528bc9..03bec019bd 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -224,3 +224,14 @@ color: var(--dsw-alias-label-tertiary); font-style: italic; } + +.image { + display: block; + width: auto; + max-width: 100%; + height: auto; + margin: 0; + border-radius: 8px; + background: var(--dsw-alias-bg-base); + object-fit: contain; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 639f53dbb1..ff6f543ebc 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -24,6 +24,15 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) +function remoteImageUrl(url: string): string | undefined { + try { + const protocol = new URL(url).protocol + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean): Components { return { @@ -40,7 +49,20 @@ function buildComponents(streaming: boolean): Components { ) }, - img: ({ alt = '' }) => {alt}, + img: ({ alt = '', src = '' }) => { + const imageSrc = remoteImageUrl(src) + if (imageSrc === undefined) return {alt} + return ( + {alt} + ) + }, table: ({ children }) => (
{children}
@@ -74,7 +96,8 @@ const streamingComponents = buildComponents(true) * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` renders fences plain (highlighting lands on the finalize swap). - * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + * @returns A GFM document with raw HTML, relative destinations, and unsafe + * protocols disabled; absolute HTTP(S) images render directly. */ export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) { return ( diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index b7f665c78a..40295e16cb 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -94,13 +94,35 @@ describe('MarkdownText', () => { expect(done.container.querySelector('pre.shiki')).not.toBeNull() }) - it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + it('renders absolute HTTP(S) images with bounded presentation', () => { + const markdown = [ + '![secure diagram](https://example.com/secure.png)', + '![plain diagram](http://example.com/plain.png)', + ].join('\n\n') + const { container } = render() + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('src'))).toEqual([ + 'https://example.com/secure.png', + 'http://example.com/plain.png', + ]) + for (const image of images) { + expect(image.getAttribute('loading')).toBe('lazy') + expect(image.getAttribute('decoding')).toBe('async') + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + } + }) + + it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', '', '[script](javascript:alert(1)) [relative](/settings)', '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', - '![remote diagram](https://example.com/private.png)', + '![relative diagram](private.png)', + '![absolute diagram](/workspace/private.png)', + '![file diagram](file:///workspace/private.png)', + '![script diagram](javascript:alert(1))', + '![mail diagram](mailto:dev@example.com)', ].join('\n\n') const { container } = render() @@ -112,7 +134,11 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') - expect(screen.getByText('remote diagram')).toBeTruthy() + expect(screen.getByText('relative diagram')).toBeTruthy() + expect(screen.getByText('absolute diagram')).toBeTruthy() + expect(screen.getByText('file diagram')).toBeTruthy() + expect(screen.getByText('script diagram')).toBeTruthy() + expect(screen.getByText('mail diagram')).toBeTruthy() }) it('keeps incomplete streaming Markdown renderable', () => { diff --git a/tsconfig.host.json b/tsconfig.host.json index b32c410961..795f5ae8d5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/cli/tests/**/*.ts", From 0c9e529f74ef6bffde7e03acf33a28f8abbd6d1d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:38:21 +0800 Subject: [PATCH 02/14] feat(tui): hidden mode folds a turn's assistant steps into one message The Ctrl+O hidden phase keeps one Assistant header per turn: the first step with visible text/reasoning owns it, later steps render as headerless continuations, and bodiless (tool-only) steps render nothing. Leaving hidden restores per-step headers. Pure TUI presentation; the session log is unchanged. --- ...28-consolidated-tui-presentation.i18n.yaml | 4 +- ...026-07-28-consolidated-tui-presentation.md | 2 +- ...-07-28-consolidated-tui-presentation.zh.md | 2 +- ...9-tui-hidden-mode-assistant-fold.i18n.yaml | 6 + ...26-07-29-tui-hidden-mode-assistant-fold.md | 25 ++++ ...07-29-tui-hidden-mode-assistant-fold.zh.md | 25 ++++ packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/transcript.ts | 67 +++++++-- packages/ui/tui/src/index.ts | 60 +++++++- .../tool-cards-hidden-folded.expected.txt | 46 +++++++ packages/ui/tui/tests/tui.snapshot.ts | 26 ++++ packages/ui/tui/tests/tui.spec.ts | 128 ++++++++++++++++++ 14 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md create mode 100644 packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml index 629476cf8e..43675cbd56 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.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 .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 -2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 +2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 +2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md index f87d543a69..8200c8e96c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -26,7 +26,7 @@ A tool card has one colored `Tool / ` status header over one dim body. Pre Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. -`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule. ### Cross-workspace resume diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 005e408f0e..852bd413b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -26,7 +26,7 @@ Status: implemented 注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 -`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。 ### 跨工作区恢复 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..338f90664d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.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 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md new file mode 100644 index 0000000000..e2bae1d466 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -0,0 +1,25 @@ +# Agent Note: TUI hidden mode folds a turn's assistant steps into one message + +Status: implemented + +English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) + +## Problem + +Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn. + +## Decision + +Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them. + +Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected. + +## Alternatives considered + +- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer. +- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped. +- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step. + +## Consequences + +Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame. diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md new file mode 100644 index 0000000000..583d6099f9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 + +Status: implemented + +[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 + +## 问题 + +Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript(文本记录)读作一段对话,但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。 + +## 决定 + +隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning)的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。 + +机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算,以及失败流被撤回(可能把标题移交给下一个步骤)时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。 + +## 考虑过的替代方案 + +- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。 +- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。 +- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。 + +## 后果 + +隐藏模式现在每轮次读作一条 assistant 消息;轮次之间仍由各自的标题分隔。折叠是重新计算的状态,从不存储,因此会话与持久化格式没有变化。覆盖:TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..6587bc7624 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: a65d72b3b80b992fabcb33d4b4345942b58e9147 +README.zh.md: fd056da59335af5cb9fc63fd2fb681266fadeb62 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..55e7eb3280 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..9683f6292e 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c8ba061166..300b39fe08 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -149,20 +149,28 @@ export class UserMessageComponent extends Container { } } -/** Children of a settled assistant message: optional reasoning block then the response text. */ +/** + * Children of a settled assistant message: optional reasoning block then the + * response text. A folded continuation (a later step of a turn while tool cards + * are hidden) drops the `Assistant` header and renders nothing when it has no + * visible body, so tool-only steps leave no blank segment behind. + */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, + foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) - const children: Component[] = [ - new Spacer(1), - new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), - ] - if (reasoning && showReasoning) { + const showsReasoning = reasoning !== '' && showReasoning + if (foldedContinuation && !showsReasoning && text === '') return [] + const children: Component[] = [new Spacer(1)] + if (!foldedContinuation) { + children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) + } + if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), @@ -220,6 +228,7 @@ interface StreamingBlock { export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined + private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it @@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container { readonly timing: StepTimingComponent constructor( - position: StepPosition, + /** The step's turn/step coordinates, used to group steps into their turn. */ + readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, @@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container { this.rebuild() } - private rebuild(): void { - this.clear() - const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + /** + * Mark this step as a folded continuation of its turn: no `Assistant` header, + * and no output at all while the step has no visible body. Used while tool + * cards are hidden so a turn reads as one assistant message. + * @param folded - Whether to render as a headerless continuation. + */ + setFoldedContinuation(folded: boolean): void { + if (this.foldedContinuation === folded) return + this.foldedContinuation = folded + this.rebuild() + } + + /** + * Whether the step currently renders visible reasoning or text. + * @returns `true` when a header-owning render would show a body. + */ + hasVisibleBody(): boolean { + const content = this.presentedContent() + return textBlocks(content, 'text').trim() !== '' + || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') + } + + /** The settled content when available, otherwise the streamed blocks in model order. */ + private presentedContent(): readonly ContentBlock[] { + return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) - for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { - this.addChild(child) - } + } + + private rebuild(): void { + this.clear() + const children = assistantMessageChildren( + this.presentedContent(), + this.showReasoning, + this.foldedContinuation, + this.palette, + this.mdTheme, + ) + for (const child of children) this.addChild(child) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index e2eaf89986..3d39c53957 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -335,6 +335,10 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // Assistant step components in model order per turn, for hidden-mode folding: + // with tool cards hidden, a turn keeps one Assistant header and later steps + // render as headerless continuations (see applyTurnFolding). + const assistantSteps = new Map() let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined // TUI steering submissions that the inbox has not yet claimed or discarded. @@ -614,6 +618,35 @@ export function createTuiChat( return card } + /** + * Re-derive hidden-mode folding for one turn: the first step with a visible + * body owns the turn's single Assistant header, every other step renders as a + * headerless continuation (empty ones render nothing). Any other visibility + * restores the per-step headers. + */ + const applyTurnFolding = (turn: number): void => { + const steps = assistantSteps.get(turn) + if (steps === undefined) return + let headerSeen = false + for (const step of steps) { + if (toolsVisibility !== 'hidden') { + step.setFoldedContinuation(false) + } else if (!headerSeen && step.hasVisibleBody()) { + headerSeen = true + step.setFoldedContinuation(false) + } else { + step.setFoldedContinuation(true) + } + } + } + + const registerAssistantStep = (component: StreamingAssistantComponent): void => { + const steps = assistantSteps.get(component.position.turn) ?? [] + steps.push(component) + assistantSteps.set(component.position.turn, steps) + applyTurnFolding(component.position.turn) + } + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { @@ -621,6 +654,15 @@ export function createTuiChat( /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } + const steps = assistantSteps.get(current.position.turn) + /* v8 ignore next -- every attached streaming component is registered in the fold map. */ + if (steps === undefined) return + const index = steps.indexOf(current) + /* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */ + if (index < 0) return + steps.splice(index, 1) + // A retracted step may have owned the turn's hidden-mode header. + applyTurnFolding(current.position.turn) } /** @@ -659,6 +701,7 @@ export function createTuiChat( palette, mdTheme, ) + registerAssistantStep(streaming) chat.addChild(streaming) chat.addChild(streaming.timing) } @@ -722,12 +765,20 @@ export function createTuiChat( startAssistantStep(event.data) break case 'assistant/chunk': - if (options.renderChunks) streaming?.update(event.data.chunk) + if (options.renderChunks && streaming !== undefined) { + streaming.update(event.data.chunk) + // The first streamed text/reasoning may make this step the turn's + // hidden-mode header owner (or a continuation with a visible body). + applyTurnFolding(streaming.position.turn) + } break case 'assistant/message': completedStreaming = undefined if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) - streaming?.settle(event.data.message.content) + if (streaming !== undefined) { + streaming.settle(event.data.message.content) + applyTurnFolding(streaming.position.turn) + } break case 'llm/retry': { retractFailedStreaming() @@ -838,6 +889,7 @@ export function createTuiChat( toolCards.clear() allToolCards.clear() contextCards.clear() + assistantSteps.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) @@ -956,6 +1008,9 @@ export function createTuiChat( // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') + // Hidden mode folds each turn's steps into one assistant message; other + // modes restore the per-step Assistant headers. + for (const turn of assistantSteps.keys()) applyTurnFolding(turn) appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } @@ -967,6 +1022,7 @@ export function createTuiChat( if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) + registerAssistantStep(activeStreaming) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt new file mode 100644 index 0000000000..2aacdef876 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -0,0 +1,46 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=20 bufferRow=20 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Inspecting the renderer first. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Refactor the renderer. " +10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " + style 0-46 dim +11| +12| "The renderer is sound; no refactor needed. " +13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " + style 0-46 dim +14| +15| "Tool and context cards expanded. " + style 0-31 dim +16| +17| "Tool cards hidden. " + style 0-17 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +20| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +21-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..3f6c72ab18 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -44,6 +44,7 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'tool-cards-hidden-folded', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -609,6 +610,31 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Refactor the renderer.') + appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }]) + appendToolCalls(harness.session, [ + { id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 }) + harness.session.append('step/end', { turn: 1, step: 2 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + // collapsed -> expanded -> hidden: one Assistant header, no tool card. + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8231dc34d1..66540d2cc2 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4989,6 +4989,134 @@ describe('tool cards and surface replay', () => { expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) + + /** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */ + const lastFrame = (terminal: FakeTerminal): string => terminal.output + .slice(terminal.output.lastIndexOf('\x1b[2J')) + .replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '') + + const countAssistantHeaders = (frame: string): number => frame.split('\n') + .filter(row => row.trim() === 'Assistant').length + + /** One turn with text -> tool call/result -> text across two steps. */ + const appendTwoStepTurn = (session: Awaited>['session']): void => { + appendUser(session, 'fold me') + appendAssistant(session, [{ type: 'text', text: 'first step text' }]) + session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + } + + it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => { + const result = await setup({ tools }) + appendTwoStepTurn(result.session) + await tick() + + // Collapsed (default): each step keeps its own header. + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + + // collapsed -> expanded -> hidden. + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('first step text') + expect(hidden).toContain('second step text') + expect(hidden).not.toContain('Tool / bash') + // The fold keeps model order: header text precedes the continuation. + expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text')) + + // hidden -> collapsed restores per-step headers. + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + await dispose(result) + }) + + it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => { + const result = await setup({ tools }) + // Turn 1, step 1 is tool-only; step 2 carries the turn's text. + appendUser(result.session, 'tool-only first step') + appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 }) + result.session.append('step/end', { turn: 1, step: 2 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Turn 2 keeps its own header. + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendUser(result.session, 'next turn') + result.session.append('step/start', { turn: 2, step: 1 }) + appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 }) + result.session.append('step/end', { turn: 2, step: 1 }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await tick() + + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + // One header per turn: the tool-only step neither renders a blank segment + // nor consumes turn one's header, which the late text step owns. + expect(countAssistantHeaders(hidden)).toBe(2) + expect(hidden).toContain('late turn-one text') + expect(hidden).toContain('turn-two text') + const rows = hidden.split('\n').map(row => row.trim()) + const turnOneHeader = rows.indexOf('Assistant') + expect(rows[turnOneHeader + 1]).toBe('late turn-one text') + await dispose(result) + }) + + it('folds live hidden-mode streaming once a later step shows text', async () => { + const result = await setup({ tools, status: 'running' }) + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } }) + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('live first') + expect(hidden).toContain('live second') + + // A transcript rebuild (resize) recomputes the same fold from the log. + result.terminal.resize(89) + await tick() + const rebuilt = lastFrame(result.terminal) + expect(countAssistantHeaders(rebuilt)).toBe(1) + expect(rebuilt).toContain('live second') + await dispose(result) + }) }) describe('TUI user-interaction dialogs', () => { From 651417e01caf4e0161ac8c97f0844067225d8b1d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:40:59 +0800 Subject: [PATCH 03/14] test(tui): pin the folded-snapshot clock so replay is keyless-deterministic --- .../tui/tests/snapshots/tool-cards-hidden-folded.expected.txt | 4 ++-- packages/ui/tui/tests/tui.snapshot.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt index 2aacdef876..d4da5ab39c 100644 --- a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -20,11 +20,11 @@ buffer 8| "You " style 0-2 fg=bright-magenta bold underline 9| "Refactor the renderer. " -10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " +10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " style 0-46 dim 11| 12| "The renderer is sound; no refactor needed. " -13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " +13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " style 0-46 dim 14| 15| "Tool and context cards expanded. " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 3f6c72ab18..d56db82f14 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -611,6 +611,7 @@ describe('TUI terminal-state snapshots', () => { }) it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime()) const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, config: { maxToolOutputLines: 3 }, @@ -632,6 +633,7 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.terminal.send('\x0f') }) await renderAfter(harness, () => { harness.terminal.send('\x0f') }) await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() await disposeSnapshot(harness) }) From 3899a26c87bffcf1b89cdb03ed12f29967d0f076 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 18:26:26 +0800 Subject: [PATCH 04/14] feat(tui): /details command sets card visibility and reasoning display /details reports the transcript detail state bare, jumps tool cards to collapsed|expanded|hidden, and sets or toggles reasoning blocks, sharing the closure state behind Ctrl+O/Ctrl+R via setToolsVisibility/setReasoning. Also fixes a replay defect the reasoning rebuild exposed: rebuildTranscript reused a settled StreamingAssistantComponent for a later assistant/message of the same step, overwriting the earlier content; the settled check now lives in renderEvent for both live and replay paths (untrusted-controls re-recorded with the previously dropped content present). --- .../2026-07-30-tui-details-command.i18n.yaml | 6 ++ .../feature/2026-07-30-tui-details-command.md | 31 ++++++++++ .../2026-07-30-tui-details-command.zh.md | 31 ++++++++++ packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 62 ++++++++++++++++--- .../snapshots/details-command.expected.txt | 45 ++++++++++++++ .../snapshots/disposed-terminal.expected.txt | 54 ++++++++-------- .../snapshots/errors-and-help.expected.txt | 52 +++++++++------- .../snapshots/untrusted-controls.expected.txt | 55 ++++++++-------- packages/ui/tui/tests/tui.snapshot.ts | 33 ++++++++++ packages/ui/tui/tests/tui.spec.ts | 33 ++++++++++ 12 files changed, 316 insertions(+), 90 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md create mode 100644 packages/ui/tui/tests/snapshots/details-command.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..df7079c9b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md +2026-07-30-tui-details-command.md: f17c168cb87d546ca158eddef7dd96fb0ab8be2d +2026-07-30-tui-details-command.zh.md: 48e9a8d3f18c51982b8678e39d9315ffe08b015f diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md new file mode 100644 index 0000000000..f17c168cb8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -0,0 +1,31 @@ +# Agent Note: /details command for transcript detail state + +Status: implemented + +English | [中文](2026-07-30-tui-details-command.zh.md) + +## Problem + +The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all. + +## Decision + +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` reports the current state in one notice. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. The command mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. + +A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. + +The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present. + +## Alternatives considered + +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as a state report. + +**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. + +**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts. + +## Consequences + +- A user can jump to any detail mode, set both dimensions at once, and query the state — including on terminals that intercept Ctrl+O/Ctrl+R. +- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the command surface and the fixed replay together. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md new file mode 100644 index 0000000000..48e9a8d3f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 用于 transcript 细节状态的 /details 命令 + +Status: implemented + +[English](2026-07-30-tui-details-command.md) | 中文 + +## Problem + +TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。 + +## Decision + +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 用一条通知报告当前状态。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。命令改动的是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 + +组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 + +reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent` 的 `assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照(reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。 + +## Alternatives considered + +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为状态报告更有用。 + +**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 + +**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。 + +## Consequences + +- 用户可以跳到任意细节模式、一次设置两个维度并查询状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照同时固定命令表面与修复后的重放。 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 55e7eb3280..23ca8343ec 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 9683f6292e..40aa849838 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 3d39c53957..6470e70114 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -774,7 +774,9 @@ export function createTuiChat( break case 'assistant/message': completedStreaming = undefined - if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) + // A settled component stays attached but never absorbs a later message + // of the same step; both the live and replay paths start a new one. + if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data) if (streaming !== undefined) { streaming.settle(event.data.message.content) applyTurnFolding(streaming.position.turn) @@ -999,11 +1001,8 @@ export function createTuiChat( // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) - const toggleTools = (): void => { - // The cycle order puts the two common reading modes adjacent: preview -> - // full detail -> conversation-only, then back to the preview default. - toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' - : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' + const setToolsVisibility = (next: ToolCardVisibility): void => { + toolsVisibility = next for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. @@ -1014,8 +1013,15 @@ export function createTuiChat( appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } - const toggleReasoning = (): void => { - showReasoning = !showReasoning + const toggleTools = (): void => { + // The cycle order puts the two common reading modes adjacent: preview -> + // full detail -> conversation-only, then back to the preview default. + setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded' + : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed') + } + + const setReasoning = (show: boolean): void => { + showReasoning = show const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ @@ -1029,6 +1035,39 @@ export function createTuiChat( appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } + const toggleReasoning = (): void => { setReasoning(!showReasoning) } + + // `/details` names the same transcript-detail state the Ctrl+O cycle and + // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. + const runDetails = (rawInput: string): CommandResult => { + const tokens = rawInput.split(/\s+/u).filter(token => token !== '') + if (tokens.length === 0) { + appendNotice(`Tool and context cards ${toolsVisibility}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + return { kind: 'success' } + } + let visibility: ToolCardVisibility | undefined + let reasoning: boolean | undefined + for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) { + if (token === 'collapsed' || token === 'expanded' || token === 'hidden') { + visibility = token + } else if (token === 'reasoning') { + const value = tokens[0] + if (value === 'on' || value === 'off') { + tokens.shift() + reasoning = value === 'on' + } else { + reasoning = !showReasoning + } + } else { + return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` } + } + } + // Reasoning first: its transcript rebuild would drop the visibility notice. + if (reasoning !== undefined) setReasoning(reasoning) + if (visibility !== undefined) setToolsVisibility(visibility) + return { kind: 'success' } + } + const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` @@ -1214,6 +1253,12 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'details', + description: 'Show or set tool-card visibility and reasoning display', + input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, + handler: ({ rawInput }) => runDetails(rawInput), + }) commandCtx.commands.register({ name: 'palette', description: 'Show every color and attribute role this terminal renders', @@ -1553,7 +1598,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn - if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // A replacement mutates only the model surface, so the rendered transcript // keeps what it already showed; a landed summary checkpoint adds its marker. if (isReplacementSurfaceEvent(event)) { diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt new file mode 100644 index 0000000000..4e84695616 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -0,0 +1,45 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=19 bufferRow=19 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| +16| "Tool and context cards hidden; reasoning blocks hidden. " + style 0-54 dim +17| +18| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +19| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +20-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index a6a73566d6..aa41ca137d 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=37 base=5 viewport=5 +terminal 92x32 buffer=normal length=39 base=7 viewport=7 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=36 +cursor visible column=0 viewportRow=31 bufferRow=38 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,48 +29,52 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" + style 0-91 dim +15| "and reasoning display " + style 0-20 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -36| +38| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b8b62bca11..48c25cf8fa 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=36 base=4 viewport=4 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=35 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,47 +29,51 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" + style 0-91 dim +15| "and reasoning display " + style 0-20 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 74cdd416d9..e5a625afc0 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -13,46 +13,41 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| -6| "You " +5| "Reasoning " + style 0-8 dim italic +6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim italic +7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +8| "Model wait 0.0s " + style 0-14 dim +9| +10| "You " style 0-2 fg=bright-magenta bold underline -7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -8| -9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| +15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-81 fg=green -10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-52 dim -12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-58 dim -13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " +19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-56 fg=red -14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " +20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " style 0-46 dim -15| -16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +21| +22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-61 dim -17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -18| -19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +24| +25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -20-21| -22| "Plan" - style 0-3 fg=bright-magenta bold -23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" - style 0-17 fg=bright-magenta bold - style 18-31 dim - style 34-50 dim - style 53-57 dim - style 60-69 dim -25| " dsh > " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse 26| " " 27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d56db82f14..abea27e69e 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -45,6 +45,7 @@ const CHECKPOINTS = [ 'advanced-cards-collapsed', 'advanced-cards-expanded', 'tool-cards-hidden-folded', + 'details-command', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -637,6 +638,38 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins /details jumping card visibility and reasoning display to named states', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Inspect the renderer.') + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' }, + { type: 'text', text: 'Running the check now.' }, + ]) + appendToolCalls(harness.session, [ + { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await renderAfter(harness, () => { + harness.terminal.send('/details hidden reasoning off') + harness.terminal.send('\r') + }) + await renderAfter(harness, () => { + harness.terminal.send('/details') + harness.terminal.send('\r') + }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 66540d2cc2..c532c5a67d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2510,6 +2510,39 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('/details reports and sets card visibility and reasoning display', async () => { + const result = await setup() + const run = async (line: string): Promise => { + result.terminal.send(line) + result.terminal.send('\r') + await tick() + } + + await run('/details') + expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks shown.') + + await run('/details hidden') + expect(result.terminal.output).toContain('Tool cards hidden.') + + await run('/details expanded reasoning off') + expect(result.terminal.output).toContain('Tool and context cards expanded.') + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + await run('/details reasoning on') + expect(result.terminal.output).toContain('Reasoning blocks shown.') + + // Bare `reasoning` toggles: shown -> hidden, confirmed by the status line. + await run('/details reasoning') + await run('/details collapsed') + await run('/details') + expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks hidden.') + + await run('/details bogus') + expect(result.terminal.output).toContain('Unknown /details argument "bogus"') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From 2c98f35a86e70ef943b05c11cfdf42d5fc4897bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 19:52:58 +0800 Subject: [PATCH 05/14] feat(tui): bare /details opens a keyboard selector DetailsDialog is a centered SelectList over the five transcript-detail states (three tool-card phases, reasoning shown/hidden); it preselects the current phase, marks both current values, applies on Enter, and cancels on Esc/Ctrl+C. Width is the new detailsDialogWidth config key. The argument grammar is unchanged and shares the same setters. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 11 +-- .../2026-07-30-tui-details-command.zh.md | 11 +-- docs/config-catalog.md | 4 +- packages/ui/tui/src/components/dialogs.ts | 58 ++++++++++++++++ packages/ui/tui/src/config.ts | 7 ++ packages/ui/tui/src/index.ts | 32 ++++++++- .../snapshots/details-command.expected.txt | 11 ++- .../snapshots/details-selector.expected.txt | 66 ++++++++++++++++++ .../snapshots/disposed-terminal.expected.txt | 6 +- .../snapshots/errors-and-help.expected.txt | 6 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 68 +++++++++++++++++-- 13 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/details-selector.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index df7079c9b8..33278b66d2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: f17c168cb87d546ca158eddef7dd96fb0ab8be2d -2026-07-30-tui-details-command.zh.md: 48e9a8d3f18c51982b8678e39d9315ffe08b015f +2026-07-30-tui-details-command.md: c14461aa08790033026eead38f53d4b8e686bbbb +2026-07-30-tui-details-command.zh.md: e9c9787a4911f29f49e7709d1ba673dc1a25e8a1 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index f17c168cb8..c14461aa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` reports the current state in one notice. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. The command mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector over the five detail states — the three tool-card phases and reasoning shown/hidden — that preselects the current phase, marks both current values, applies the highlighted state on Enter, and cancels on Esc or Ctrl+C; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -18,7 +18,9 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## Alternatives considered -**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as a state report. +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target. + +**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes. **Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. @@ -26,6 +28,7 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## Consequences -- A user can jump to any detail mode, set both dimensions at once, and query the state — including on terminals that intercept Ctrl+O/Ctrl+R. +- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the command surface and the fixed replay together. +- The selector applies one dimension per confirm; a combined change still needs the argument form. Enter on the already-current row re-applies it idempotently and repeats its notice. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with its current-state markers. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index 48e9a8d3f1..e9c9787a49 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 用一条通知报告当前状态。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。命令改动的是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,列出五个细节状态——三个工具卡片阶段与 reasoning 显示/隐藏——预选当前阶段并标记两个当前值,Enter 应用高亮状态并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -18,7 +18,9 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Alternatives considered -**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为状态报告更有用。 +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 **拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 @@ -26,6 +28,7 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Consequences -- 用户可以跳到任意细节模式、一次设置两个维度并查询状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照同时固定命令表面与修复后的重放。 +- 选择器每次确认只应用一个维度;组合变更仍需参数形式。在已是当前值的行上按 Enter 会幂等地重新应用并重复其通知。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定带当前值标记的打开选择器。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2288d7ffbe..0f04e0a955 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2034,6 +2034,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -2067,7 +2069,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..f59ff747be 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -34,6 +34,7 @@ import type { import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' +import type { ToolCardVisibility } from './transcript.ts' import { renderTuiPromptTemplate, type TuiPromptTemplateToken, @@ -432,6 +433,63 @@ export class ModelDialog implements Component { } } +/** One transcript-detail state the details selector applies on confirm. */ +export type DetailsSelection = + | { readonly kind: 'tools'; readonly visibility: ToolCardVisibility } + | { readonly kind: 'reasoning'; readonly show: boolean } + +/** + * Keyboard selector over the transcript detail states: the three tool-card + * visibility phases and reasoning-block display. Enter applies the highlighted + * state and closes; Esc or Ctrl+C closes without changing anything. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + + constructor( + visibility: ToolCardVisibility, + showReasoning: boolean, + private readonly palette: Palette, + done: (selection: DetailsSelection) => void, + private readonly cancel: () => void, + ) { + const current = (isCurrent: boolean): string => isCurrent ? ' — current' : '' + const items: SelectItem[] = [ + { value: 'collapsed', label: 'Tool cards · collapsed', description: `head/tail preview${current(visibility === 'collapsed')}` }, + { value: 'expanded', label: 'Tool cards · expanded', description: `full bodies${current(visibility === 'expanded')}` }, + { value: 'hidden', label: 'Tool cards · hidden', description: `conversation only${current(visibility === 'hidden')}` }, + { value: 'reasoning-shown', label: 'Reasoning · shown', description: `show reasoning blocks${current(showReasoning)}` }, + { value: 'reasoning-hidden', label: 'Reasoning · hidden', description: `omit reasoning blocks${current(!showReasoning)}` }, + ] + this.list = new SelectList(items, items.length, dialogSelectTheme(palette)) + this.list.setSelectedIndex(items.findIndex(item => item.value === visibility)) + this.list.onSelect = (item) => { + done(item.value === 'reasoning-shown' || item.value === 'reasoning-hidden' + ? { kind: 'reasoning', show: item.value === 'reasoning-shown' } + : { kind: 'tools', visibility: item.value as ToolCardVisibility }) + } + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() + else this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Transcript details', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ move • Enter apply • Esc cancel'), + ], width, this.palette) + } +} + /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index def548861f..59a12c0b96 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -46,6 +46,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -70,6 +72,7 @@ const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) @@ -102,6 +105,7 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + detailsDialogWidth: detailsDialogWidthSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -142,6 +146,7 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -171,6 +176,7 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + detailsDialogWidth: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -196,6 +202,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + detailsDialogWidth: config?.detailsDialogWidth ?? 72, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 6470e70114..7fd76a1a6f 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -104,6 +104,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -112,6 +113,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -1037,12 +1039,38 @@ export function createTuiChat( const toggleReasoning = (): void => { setReasoning(!showReasoning) } + // The selector and the argument grammar mutate the same closure state the + // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges. + let detailsOverlay: TuiOverlaySession | undefined + const showDetailsSelector = (): void => { + void detailsOverlay?.close() + const session = overlayManager.open({ + create: () => new DetailsDialog( + toolsVisibility, + showReasoning, + palette, + (selection: DetailsSelection) => { + void session.close() + if (selection.kind === 'reasoning') setReasoning(selection.show) + else setToolsVisibility(selection.visibility) + }, + () => { void session.close() }, + ), + options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 }, + }) + detailsOverlay = session + void session.closed.then(() => { + if (detailsOverlay === session) detailsOverlay = undefined + }) + requestRender() + } + // `/details` names the same transcript-detail state the Ctrl+O cycle and // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. const runDetails = (rawInput: string): CommandResult => { const tokens = rawInput.split(/\s+/u).filter(token => token !== '') if (tokens.length === 0) { - appendNotice(`Tool and context cards ${toolsVisibility}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + showDetailsSelector() return { kind: 'success' } } let visibility: ToolCardVisibility | undefined @@ -1255,7 +1283,7 @@ export function createTuiChat( }) commandCtx.commands.register({ name: 'details', - description: 'Show or set tool-card visibility and reasoning display', + description: 'Select tool-card visibility and reasoning display', input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, handler: ({ rawInput }) => runDetails(rawInput), }) diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt index 4e84695616..4d971c88df 100644 --- a/packages/ui/tui/tests/snapshots/details-command.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -1,7 +1,7 @@ terminal 100x40 buffer=normal length=40 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=19 bufferRow=19 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,17 +29,14 @@ buffer 14| "Tool cards hidden. " style 0-17 dim 15| -16| "Tool and context cards hidden; reasoning blocks hidden. " - style 0-54 dim -17| -18| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -19| " dsh > " +17| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -20-39| +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..ad6514d4e9 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,66 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=39 bufferRow=39 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| " ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 14-85 fg=bright-magenta +16| "/workspace/pro│ Tool cards · collapsed head/tail preview │ " + style 0-13 fg=bright-magenta bold + style 14-14 fg=bright-magenta + style 40-66 dim + style 85-85 fg=bright-magenta +17| " dsh > │ Tool cards · expanded full bodies │ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse + style 14-14 fg=bright-magenta + style 39-60 dim + style 85-85 fg=bright-magenta +18| " │ → Tool cards · hidden conversation only — current │ " + style 14-14 fg=bright-magenta + style 16-76 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning · shown show reasoning blocks │ " + style 14-14 fg=bright-magenta + style 35-70 dim + style 85-85 fg=bright-magenta +20| " │ Reasoning · hidden omit reasoning blocks — current │ " + style 14-14 fg=bright-magenta + style 36-80 dim + style 85-85 fg=bright-magenta +21| " │ │ " + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +22| " │ ↑/↓ move • Enter apply • Esc cancel │ " + style 14-14 fg=bright-magenta + style 16-50 dim + style 85-85 fg=bright-magenta +23| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 14-85 fg=bright-magenta +24-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index aa41ca137d..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 48c25cf8fa..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index abea27e69e..c0b6d44f88 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -46,6 +46,7 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'tool-cards-hidden-folded', 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -661,11 +662,14 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('/details hidden reasoning off') harness.terminal.send('\r') }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the selector, preselecting and marking the current + // hidden/reasoning-off state. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') }) - await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) nowSpy.mockRestore() await disposeSnapshot(harness) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c532c5a67d..093d8ad285 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -186,6 +186,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -210,6 +211,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -226,6 +228,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2510,7 +2513,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('/details reports and sets card visibility and reasoning display', async () => { + it('/details sets card visibility and reasoning display from arguments', async () => { const result = await setup() const run = async (line: string): Promise => { result.terminal.send(line) @@ -2518,9 +2521,6 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() } - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks shown.') - await run('/details hidden') expect(result.terminal.output).toContain('Tool cards hidden.') @@ -2531,11 +2531,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await run('/details reasoning on') expect(result.terminal.output).toContain('Reasoning blocks shown.') - // Bare `reasoning` toggles: shown -> hidden, confirmed by the status line. + // Bare `reasoning` toggles: shown -> hidden. + const toggleOutput = result.terminal.output.length await run('/details reasoning') + expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.') await run('/details collapsed') - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks hidden.') + expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.') await run('/details bogus') expect(result.terminal.output).toContain('Unknown /details argument "bogus"') @@ -2543,6 +2544,59 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('bare /details opens the transcript-details selector and applies the confirmed state', async () => { + const result = await setup() + const open = async (): Promise => { + const from = result.terminal.output.length + result.terminal.send('/details') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') }) + return from + } + + await open() + expect(result.terminal.output).toContain('Tool cards · collapsed') + expect(result.terminal.output).toContain('head/tail preview — current') + expect(result.terminal.output).toContain('show reasoning blocks — current') + + // A second /details while the selector is open replaces the overlay + // instead of stacking a second one behind it. + await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) + await tick() + + // Esc cancels without touching the state. + const cancelOutput = result.terminal.output.length + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') + + // Enter on the next visibility row applies it and closes. + await open() + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + + // The reopened selector preselects the current phase and marks it. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('full bodies — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Ctrl+C also cancels. + const ctrlCOutput = result.terminal.output.length + await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks shown.') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From f13003df757125176692740cac16898251457e84 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 23:45:50 +0800 Subject: [PATCH 06/14] feat(tui): two-entry /details selector with Tab cycling DetailsDialog now shows one entry per dimension (Tool cards, Reasoning) seeded with the current values; Tab cycles the highlighted entry's pending value (rendered as current -> pending), Enter applies every changed dimension in one confirm, Esc/Ctrl+C cancels. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 6 +- .../2026-07-30-tui-details-command.zh.md | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/components/dialogs.ts | 79 +++++++++++++------ packages/ui/tui/src/index.ts | 5 +- .../snapshots/details-selector.expected.txt | 58 ++++++-------- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 36 ++++++--- 11 files changed, 131 insertions(+), 85 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index 33278b66d2..8e6c202a5e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: c14461aa08790033026eead38f53d4b8e686bbbb -2026-07-30-tui-details-command.zh.md: e9c9787a4911f29f49e7709d1ba673dc1a25e8a1 +2026-07-30-tui-details-command.md: 5de80ceb3ad78a949268c31e9c1d1a50c956b90f +2026-07-30-tui-details-command.zh.md: f74d0658cb776752bf3da1682291de882638310a diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index c14461aa08..5de80ceb3a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector over the five detail states — the three tool-card phases and reasoning shown/hidden — that preselects the current phase, marks both current values, applies the highlighted state on Enter, and cancels on Esc or Ctrl+C; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values: Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension in one confirm and closes, and Esc or Ctrl+C cancels; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -30,5 +30,5 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE - A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- The selector applies one dimension per confirm; a combined change still needs the argument form. Enter on the already-current row re-applies it idempotently and repeats its notice. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with its current-state markers. +- The selector applies only changed dimensions on confirm, so Enter without a Tab closes silently; a two-dimension change is one open-Tab-Enter interaction. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with a Tab-cycled `hidden → collapsed` pending value. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index e9c9787a49..f74d0658cb 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,列出五个细节状态——三个工具卡片阶段与 reasoning 显示/隐藏——预选当前阶段并标记两个当前值,Enter 应用高亮状态并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始:Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 一次确认应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -30,5 +30,5 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 - 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 选择器每次确认只应用一个维度;组合变更仍需参数形式。在已是当前值的行上按 Enter 会幂等地重新应用并重复其通知。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定带当前值标记的打开选择器。 +- 选择器确认时只应用已改变的维度,因此未按 Tab 直接 Enter 会静默关闭;两个维度的变更是一次打开-Tab-Enter 交互。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定经 Tab 循环出 `hidden → collapsed` 待定值的打开选择器。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 6587bc7624..af9ba4a67a 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: a65d72b3b80b992fabcb33d4b4345942b58e9147 -README.zh.md: fd056da59335af5cb9fc63fd2fb681266fadeb62 +README.md: 7230e75535ce5710e12040e026bc77a1782ed6dd +README.zh.md: 3079b797b670de64cfdc1faa9d002ee04a6e28dd diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 23ca8343ec..8ef5f20ca0 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values, where Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension and closes, and Esc or Ctrl+C cancels without changing anything; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -32,6 +32,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. + `/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. @@ -57,6 +58,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `detailsDialogWidth` | `72` | Transcript-details selector width in columns | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 40aa849838..48cb25fb94 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始,Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消且不改变任何东西;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 @@ -32,6 +32,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 + `/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 @@ -57,6 +58,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `questionDialogMaxHeight` | `20` | 问题面板最大行数 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index f59ff747be..93c416b230 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -433,40 +433,70 @@ export class ModelDialog implements Component { } } -/** One transcript-detail state the details selector applies on confirm. */ -export type DetailsSelection = - | { readonly kind: 'tools'; readonly visibility: ToolCardVisibility } - | { readonly kind: 'reasoning'; readonly show: boolean } +/** Both transcript-detail dimensions the details selector applies on confirm. */ +export interface DetailsSelection { + readonly visibility: ToolCardVisibility + readonly showReasoning: boolean +} + +const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] /** - * Keyboard selector over the transcript detail states: the three tool-card - * visibility phases and reasoning-block display. Enter applies the highlighted - * state and closes; Esc or Ctrl+C closes without changing anything. + * Keyboard selector over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's pending + * value, Enter applies both pending values and closes, Esc or Ctrl+C closes + * without changing anything. A pending value renders as `current → pending`. */ export class DetailsDialog implements Component { private readonly list: SelectList + private readonly toolsItem: SelectItem + private readonly reasoningItem: SelectItem + private pendingVisibility: ToolCardVisibility + private pendingReasoning: boolean constructor( - visibility: ToolCardVisibility, - showReasoning: boolean, + private readonly visibility: ToolCardVisibility, + private readonly showReasoning: boolean, private readonly palette: Palette, done: (selection: DetailsSelection) => void, private readonly cancel: () => void, ) { - const current = (isCurrent: boolean): string => isCurrent ? ' — current' : '' - const items: SelectItem[] = [ - { value: 'collapsed', label: 'Tool cards · collapsed', description: `head/tail preview${current(visibility === 'collapsed')}` }, - { value: 'expanded', label: 'Tool cards · expanded', description: `full bodies${current(visibility === 'expanded')}` }, - { value: 'hidden', label: 'Tool cards · hidden', description: `conversation only${current(visibility === 'hidden')}` }, - { value: 'reasoning-shown', label: 'Reasoning · shown', description: `show reasoning blocks${current(showReasoning)}` }, - { value: 'reasoning-hidden', label: 'Reasoning · hidden', description: `omit reasoning blocks${current(!showReasoning)}` }, - ] - this.list = new SelectList(items, items.length, dialogSelectTheme(palette)) - this.list.setSelectedIndex(items.findIndex(item => item.value === visibility)) - this.list.onSelect = (item) => { - done(item.value === 'reasoning-shown' || item.value === 'reasoning-hidden' - ? { kind: 'reasoning', show: item.value === 'reasoning-shown' } - : { kind: 'tools', visibility: item.value as ToolCardVisibility }) + this.pendingVisibility = visibility + this.pendingReasoning = showReasoning + this.toolsItem = { value: 'tools', label: 'Tool cards', description: this.describeTools() } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.describeReasoning() } + this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) + this.list.onSelect = () => { + done({ visibility: this.pendingVisibility, showReasoning: this.pendingReasoning }) + } + } + + /** `current → pending` when Tab moved the value, otherwise the current value. */ + private static pendingLabel(current: string, pending: string): string { + return pending === current ? current : `${current} → ${pending}` + } + + private describeTools(): string { + return DetailsDialog.pendingLabel(this.visibility, this.pendingVisibility) + } + + private describeReasoning(): string { + const label = (show: boolean): string => show ? 'shown' : 'hidden' + return DetailsDialog.pendingLabel(label(this.showReasoning), label(this.pendingReasoning)) + } + + /** Cycle the highlighted entry's pending value one step. */ + private cyclePending(): void { + const selected = this.list.getSelectedItem() + /* v8 ignore next -- the two-entry list always has a selection. */ + if (selected === null) return + if (selected.value === 'tools') { + const index = TOOL_CARD_PHASES.indexOf(this.pendingVisibility) + this.pendingVisibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.describeTools() + } else { + this.pendingReasoning = !this.pendingReasoning + this.reasoningItem.description = this.describeReasoning() } } @@ -476,6 +506,7 @@ export class DetailsDialog implements Component { handleInput(data: string): void { if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() + else if (matchesKey(data, Key.tab)) this.cyclePending() else this.list.handleInput(data) this.invalidate() } @@ -485,7 +516,7 @@ export class DetailsDialog implements Component { return renderDialog('Transcript details', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ move • Enter apply • Esc cancel'), + this.palette.dim('↑/↓ move • Tab cycle • Enter apply • Esc cancel'), ], width, this.palette) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7fd76a1a6f..9a07b1f7c4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1051,8 +1051,9 @@ export function createTuiChat( palette, (selection: DetailsSelection) => { void session.close() - if (selection.kind === 'reasoning') setReasoning(selection.show) - else setToolsVisibility(selection.visibility) + // Reasoning first: its transcript rebuild would drop the card notice. + if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) + if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) }, () => { void session.close() }, ), diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt index ad6514d4e9..d6cacf8cef 100644 --- a/packages/ui/tui/tests/snapshots/details-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -28,39 +28,33 @@ buffer 13| 14| "Tool cards hidden. " style 0-17 dim -15| " ╭ Transcript details ──────────────────────────────────────────────────╮ " - style 14-85 fg=bright-magenta -16| "/workspace/pro│ Tool cards · collapsed head/tail preview │ " - style 0-13 fg=bright-magenta bold - style 14-14 fg=bright-magenta - style 40-66 dim - style 85-85 fg=bright-magenta -17| " dsh > │ Tool cards · expanded full bodies │ " +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +17| " dsh > ╭ Transcript details ──────────────────────────────────────────────────╮ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse - style 14-14 fg=bright-magenta - style 39-60 dim - style 85-85 fg=bright-magenta -18| " │ → Tool cards · hidden conversation only — current │ " - style 14-14 fg=bright-magenta - style 16-76 fg=bright-magenta inverse - style 85-85 fg=bright-magenta -19| " │ Reasoning · shown show reasoning blocks │ " - style 14-14 fg=bright-magenta - style 35-70 dim - style 85-85 fg=bright-magenta -20| " │ Reasoning · hidden omit reasoning blocks — current │ " - style 14-14 fg=bright-magenta - style 36-80 dim - style 85-85 fg=bright-magenta -21| " │ │ " - style 14-14 fg=bright-magenta - style 85-85 fg=bright-magenta -22| " │ ↑/↓ move • Enter apply • Esc cancel │ " - style 14-14 fg=bright-magenta - style 16-50 dim - style 85-85 fg=bright-magenta -23| " ╰──────────────────────────────────────────────────────────────────────╯ " style 14-85 fg=bright-magenta -24-39| +18| " │ → Tool cards hidden → collapsed │ " + style 14-14 fg=bright-magenta + style 16-67 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning hidden │ " + style 14-14 fg=bright-magenta + style 27-55 dim + style 85-85 fg=bright-magenta +20| " │ │ " + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +21| " │ ↑/↓ move • Tab cycle • Enter apply • Esc cancel │ " + style 14-14 fg=bright-magenta + style 16-62 dim + style 85-85 fg=bright-magenta +22| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 14-85 fg=bright-magenta +23-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index c0b6d44f88..6931df723f 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -663,11 +663,13 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('details-command', harness.terminal, { includeScrollback: true }) - // Bare /details opens the selector, preselecting and marking the current - // hidden/reasoning-off state. + // Bare /details opens the two-entry selector seeded with the current + // hidden/reasoning-off state; one Tab renders the tool-card entry's + // pending cycle as `hidden → collapsed`. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') + harness.terminal.send('\t') }) await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) nowSpy.mockRestore() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 093d8ad285..1f4272bb6b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2554,10 +2554,9 @@ describe('pi-tui chat lifecycle and transcript', () => { return from } - await open() - expect(result.terminal.output).toContain('Tool cards · collapsed') - expect(result.terminal.output).toContain('head/tail preview — current') - expect(result.terminal.output).toContain('show reasoning blocks — current') + const opened = await open() + expect(result.terminal.output.slice(opened)).toContain('Tool cards') + expect(result.terminal.output.slice(opened)).toContain('Reasoning') // A second /details while the selector is open replaces the overlay // instead of stacking a second one behind it. @@ -2570,22 +2569,37 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') - // Enter on the next visibility row applies it and closes. - await open() - result.terminal.send('\x1b[B') + // Tab cycles the highlighted entry's pending value; Enter applies it. + const cycled = await open() + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(cycled)).toContain('collapsed → expanded') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Tool and context cards expanded.') - // The reopened selector preselects the current phase and marks it. + // Both entries apply in one confirm: cycle tool cards through the + // wraparound back to collapsed and toggle reasoning off. const reopened = await open() - expect(result.terminal.output.slice(reopened)).toContain('full bodies — current') - result.terminal.send('\x1b[B') - result.terminal.send('\x1b[B') + result.terminal.send('\t') + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(reopened)).toContain('expanded → collapsed') result.terminal.send('\x1b[B') + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(reopened)).toContain('shown → hidden') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Reasoning blocks hidden.') + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // Enter with no pending change closes without a notice. + const unchanged = await open() + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(unchanged)).not.toContain('Tool and context cards') + expect(result.terminal.output.slice(unchanged)).not.toContain('Reasoning blocks') // Ctrl+C also cancels. const ctrlCOutput = result.terminal.output.length From 5b144238a28352d204926d444bd8f7cbbb20cf9e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 23:55:33 +0800 Subject: [PATCH 07/14] feat(tui): /details Tab toggles apply immediately Drop the pending state and confirm step: Tab cycles the highlighted entry and applies at once, so the transcript behind the dialog is the live preview; Enter/Esc/Ctrl+C just close. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 6 +- .../2026-07-30-tui-details-command.zh.md | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 65 +++++++------------ packages/ui/tui/src/index.ts | 3 +- .../snapshots/details-selector.expected.txt | 60 ++++++++++------- packages/ui/tui/tests/tui.snapshot.ts | 7 +- packages/ui/tui/tests/tui.spec.ts | 50 ++++++-------- 11 files changed, 98 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index 8e6c202a5e..5a1b64f6b3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: 5de80ceb3ad78a949268c31e9c1d1a50c956b90f -2026-07-30-tui-details-command.zh.md: f74d0658cb776752bf3da1682291de882638310a +2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c +2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index 5de80ceb3a..fb7c4dfaed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values: Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension in one confirm and closes, and Esc or Ctrl+C cancels; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -30,5 +30,5 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE - A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- The selector applies only changed dimensions on confirm, so Enter without a Tab closes silently; a two-dimension change is one open-Tab-Enter interaction. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with a Tab-cycled `hidden → collapsed` pending value. +- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index f74d0658cb..5f9e033311 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始:Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 一次确认应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值:Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览,Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -30,5 +30,5 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 - 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 选择器确认时只应用已改变的维度,因此未按 Tab 直接 Enter 会静默关闭;两个维度的变更是一次打开-Tab-Enter 交互。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定经 Tab 循环出 `hidden → collapsed` 待定值的打开选择器。 +- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index af9ba4a67a..1bef78a96e 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: 7230e75535ce5710e12040e026bc77a1782ed6dd -README.zh.md: 3079b797b670de64cfdc1faa9d002ee04a6e28dd +README.md: b6d0ab8e143f14ea2826054f72cf5a778ffa9bbb +README.zh.md: addce9f3b55c081208b0849bc00522fa0a22e295 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8ef5f20ca0..66d7141994 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values, where Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension and closes, and Esc or Ctrl+C cancels without changing anything; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 48cb25fb94..9b67d1a4a2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始,Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消且不改变任何东西;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 93c416b230..946055f1d8 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -433,7 +433,7 @@ export class ModelDialog implements Component { } } -/** Both transcript-detail dimensions the details selector applies on confirm. */ +/** Both transcript-detail dimensions, applied immediately on each Tab. */ export interface DetailsSelection { readonly visibility: ToolCardVisibility readonly showReasoning: boolean @@ -442,62 +442,47 @@ export interface DetailsSelection { const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] /** - * Keyboard selector over the two transcript-detail entries — tool-card - * visibility and reasoning display. Tab cycles the highlighted entry's pending - * value, Enter applies both pending values and closes, Esc or Ctrl+C closes - * without changing anything. A pending value renders as `current → pending`. + * Keyboard toggle over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's value + * and applies it immediately, so the transcript behind the dialog is the live + * preview; Enter, Esc, or Ctrl+C closes. */ export class DetailsDialog implements Component { private readonly list: SelectList private readonly toolsItem: SelectItem private readonly reasoningItem: SelectItem - private pendingVisibility: ToolCardVisibility - private pendingReasoning: boolean constructor( - private readonly visibility: ToolCardVisibility, - private readonly showReasoning: boolean, + private visibility: ToolCardVisibility, + private showReasoning: boolean, private readonly palette: Palette, - done: (selection: DetailsSelection) => void, - private readonly cancel: () => void, + private readonly apply: (selection: DetailsSelection) => void, + private readonly close: () => void, ) { - this.pendingVisibility = visibility - this.pendingReasoning = showReasoning - this.toolsItem = { value: 'tools', label: 'Tool cards', description: this.describeTools() } - this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.describeReasoning() } + this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) - this.list.onSelect = () => { - done({ visibility: this.pendingVisibility, showReasoning: this.pendingReasoning }) - } + this.list.onSelect = close } - /** `current → pending` when Tab moved the value, otherwise the current value. */ - private static pendingLabel(current: string, pending: string): string { - return pending === current ? current : `${current} → ${pending}` + private reasoningLabel(): string { + return this.showReasoning ? 'shown' : 'hidden' } - private describeTools(): string { - return DetailsDialog.pendingLabel(this.visibility, this.pendingVisibility) - } - - private describeReasoning(): string { - const label = (show: boolean): string => show ? 'shown' : 'hidden' - return DetailsDialog.pendingLabel(label(this.showReasoning), label(this.pendingReasoning)) - } - - /** Cycle the highlighted entry's pending value one step. */ - private cyclePending(): void { + /** Cycle the highlighted entry one step and apply the new state. */ + private cycle(): void { const selected = this.list.getSelectedItem() /* v8 ignore next -- the two-entry list always has a selection. */ if (selected === null) return if (selected.value === 'tools') { - const index = TOOL_CARD_PHASES.indexOf(this.pendingVisibility) - this.pendingVisibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility - this.toolsItem.description = this.describeTools() + const index = TOOL_CARD_PHASES.indexOf(this.visibility) + this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.visibility } else { - this.pendingReasoning = !this.pendingReasoning - this.reasoningItem.description = this.describeReasoning() + this.showReasoning = !this.showReasoning + this.reasoningItem.description = this.reasoningLabel() } + this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) } invalidate(): void { @@ -505,8 +490,8 @@ export class DetailsDialog implements Component { } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() - else if (matchesKey(data, Key.tab)) this.cyclePending() + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() + else if (matchesKey(data, Key.tab)) this.cycle() else this.list.handleInput(data) this.invalidate() } @@ -516,7 +501,7 @@ export class DetailsDialog implements Component { return renderDialog('Transcript details', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ move • Tab cycle • Enter apply • Esc cancel'), + this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), ], width, this.palette) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 9a07b1f7c4..0e3e42c6a1 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1049,9 +1049,8 @@ export function createTuiChat( toolsVisibility, showReasoning, palette, + // Each Tab applies immediately; one dimension changes per call. (selection: DetailsSelection) => { - void session.close() - // Reasoning first: its transcript rebuild would drop the card notice. if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) }, diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt index d6cacf8cef..69ab18bddd 100644 --- a/packages/ui/tui/tests/snapshots/details-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -20,41 +20,53 @@ buffer 8| "You " style 0-2 fg=bright-magenta bold underline 9| "Inspect the renderer. " -10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " - style 0-46 dim -11| -12| "Reasoning blocks hidden. " +10| +11| "Assistant " + style 0-8 fg=bright-magenta bold underline +12| +13| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +14| "$ pnpm run test:coverage " style 0-23 dim -13| -14| "Tool cards hidden. " +15| "/workspace/project " style 0-17 dim -15| -16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" - style 0-17 fg=bright-magenta bold - style 18-31 dim - style 34-50 dim - style 53-57 dim - style 60-69 dim -17| " dsh > ╭ Transcript details ──────────────────────────────────────────────────╮ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse +16| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 0-7 dim style 14-85 fg=bright-magenta -18| " │ → Tool cards hidden → collapsed │ " +18| "Model wait 0.0│ → Tool cards collapsed │ " + style 0-13 dim style 14-14 fg=bright-magenta - style 16-67 fg=bright-magenta inverse + style 16-58 fg=bright-magenta inverse style 85-85 fg=bright-magenta 19| " │ Reasoning hidden │ " style 14-14 fg=bright-magenta style 27-55 dim style 85-85 fg=bright-magenta -20| " │ │ " +20| "Reasoning bloc│ │ " + style 0-13 dim style 14-14 fg=bright-magenta style 85-85 fg=bright-magenta -21| " │ ↑/↓ move • Tab cycle • Enter apply • Esc cancel │ " +21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ " style 14-14 fg=bright-magenta - style 16-62 dim + style 16-54 dim style 85-85 fg=bright-magenta -22| " ╰──────────────────────────────────────────────────────────────────────╯ " +22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ " + style 0-13 dim style 14-85 fg=bright-magenta -23-39| +23| +24| "Tool and context cards collapsed. " + style 0-32 dim +25| +26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +27| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +28-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 6931df723f..706ed7dce5 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -663,9 +663,10 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('details-command', harness.terminal, { includeScrollback: true }) - // Bare /details opens the two-entry selector seeded with the current - // hidden/reasoning-off state; one Tab renders the tool-card entry's - // pending cycle as `hidden → collapsed`. + // Bare /details opens the two-entry toggle seeded with the current + // hidden/reasoning-off state; one Tab immediately cycles tool cards + // hidden -> collapsed, so the frame pins the applied notice, the restored + // tool card behind the dialog, and the updated entry value together. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 1f4272bb6b..8fe7981cb7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2544,7 +2544,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('bare /details opens the transcript-details selector and applies the confirmed state', async () => { + it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => { const result = await setup() const open = async (): Promise => { const from = result.terminal.output.length @@ -2563,50 +2563,40 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) await tick() - // Esc cancels without touching the state. - const cancelOutput = result.terminal.output.length - result.terminal.send('\x1b') - await tick() - expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') - - // Tab cycles the highlighted entry's pending value; Enter applies it. - const cycled = await open() + // Each Tab applies one step immediately while the dialog stays open: + // collapsed -> expanded -> hidden -> collapsed (wraparound). result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(cycled)).toContain('collapsed → expanded') - result.terminal.send('\r') - await tick() expect(result.terminal.output).toContain('Tool and context cards expanded.') - - // Both entries apply in one confirm: cycle tool cards through the - // wraparound back to collapsed and toggle reasoning off. - const reopened = await open() - result.terminal.send('\t') result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(reopened)).toContain('expanded → collapsed') + expect(result.terminal.output).toContain('Tool cards hidden.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // The reasoning entry toggles the same way. result.terminal.send('\x1b[B') result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(reopened)).toContain('shown → hidden') - result.terminal.send('\r') - await tick() expect(result.terminal.output).toContain('Reasoning blocks hidden.') - expect(result.terminal.output).toContain('Tool and context cards collapsed.') - // Enter with no pending change closes without a notice. - const unchanged = await open() + // Enter closes without further changes. + const entered = result.terminal.output.length result.terminal.send('\r') await tick() - expect(result.terminal.output.slice(unchanged)).not.toContain('Tool and context cards') - expect(result.terminal.output.slice(unchanged)).not.toContain('Reasoning blocks') + expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks') - // Ctrl+C also cancels. - const ctrlCOutput = result.terminal.output.length - await open() + // Esc and Ctrl+C also close; the reopened dialog shows the live values. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('collapsed') + expect(result.terminal.output.slice(reopened)).toContain('hidden') + result.terminal.send('\x1b') + await tick() + const ctrlCOutput = await open() result.terminal.send('\x03') await tick() - expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks shown.') + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks') await dispose(result) }) From 27ab6ee05ec36dad17f5d47e389457109f70046d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:37:46 +0800 Subject: [PATCH 08/14] docs: regenerate cordis catalog and re-record TUI README pairing --- docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d92f467469..4101fcb9e6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:243`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 1bef78a96e..df5af8083c 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: b6d0ab8e143f14ea2826054f72cf5a778ffa9bbb -README.zh.md: addce9f3b55c081208b0849bc00522fa0a22e295 +README.md: 66d71419945edc9d9f5fe10bf9bcd810d05ba20c +README.zh.md: 9b67d1a4a2e4a3d27d263421cc909188ff2e34cc From 9146c97d13628200006701da513bfb9c48787c79 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 17:35:37 +0800 Subject: [PATCH 09/14] review: order detailsDialogWidth consistently across the parallel config lists --- docs/config-catalog.md | 4 ++-- packages/ui/tui/src/config.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0f04e0a955..2cd6ca2bed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2034,10 +2034,10 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number - /** Transcript-details selector width in terminal columns. */ - detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 59a12c0b96..e82aa46e08 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -46,10 +46,10 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number - /** Transcript-details selector width in terminal columns. */ - detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -72,8 +72,8 @@ const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) -const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) From 76547dfe0cc7b1db2b6cead44a7f7f2f78b53550 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 16:25:02 +0800 Subject: [PATCH 10/14] fix(web): restrict message forks to completed turn tails --- ...ions-require-completed-turn-tail.i18n.yaml | 6 ++ ...ork-actions-require-completed-turn-tail.md | 27 ++++++++ ...-actions-require-completed-turn-tail.zh.md | 27 ++++++++ ...6-07-27-web-session-fork-actions.i18n.yaml | 4 +- .../2026-07-27-web-session-fork-actions.md | 8 ++- .../2026-07-27-web-session-fork-actions.zh.md | 8 ++- apps/web/tests/message-actions.e2e.ts | 68 +++++++++++++++---- .../snapshots/message-actions/ui.expected.md | 11 ++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 2 + .../runtime/src/client/sessions/session.ts | 13 ++++ packages/client/runtime/tests/session.spec.ts | 2 + packages/client/test-runtime/src/fixtures.ts | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/AssistantMarkdown.tsx | 9 +-- .../src/client/chat/ChatView.tsx | 8 ++- .../src/client/chat/MessageIconActions.tsx | 14 ++-- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/chat/chat-flow.ts | 36 +++++++++- .../src/client/contract/slots.ts | 2 +- .../tests/chat-branch-tails.spec.tsx | 9 +++ .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 53 ++++++++++++--- .../ui-conversation/tests/diff-card.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/read-card.spec.tsx | 2 +- .../tests/search-card.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 2 +- .../ui-conversation/tests/web-card.spec.tsx | 2 +- 39 files changed, 283 insertions(+), 73 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml new file mode 100644 index 0000000000..15b7acbeba --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +2026-08-02-message-fork-actions-require-completed-turn-tail.md: 1f5ee6b7ff37709e07544f3e7c73ba1eee951f8d +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: ed897e9af56a601c8fc42379e3907e3551c6389e diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md new file mode 100644 index 0000000000..1f5ee6b7ff --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -0,0 +1,27 @@ +# Agent Note: Message fork actions require a completed turn tail + +Status: implemented + +English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md) + +## Problem + +The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix. + +## Decision + +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and exposes branch only when the boundary's last node is a user message or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node suppresses branch on earlier messages. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. + +This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. + +## Alternatives considered + +**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript. + +**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact. + +**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. + +## Consequences + +A branch icon now denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy and clock but no longer advertises branch. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover normal assistant tails, user-only tails, and suppression by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md new file mode 100644 index 0000000000..ed897e9af5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部 + +Status: implemented + +[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文 + +## 问题 + +Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理(reasoning)节点或终态错误,这些行也不会接管操作,因为它们没有内容文本 IconActions。因此,分支图标可能出现在 assistant 响应下方,而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。 + +## 决策 + +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息或含内容的 assistant 消息时才暴露分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作就会被抑制。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 + +本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 + +## 考虑过的替代方案 + +**在点击的 assistant 消息处截断事件日志。** 不予采纳:assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。 + +**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering(中途引导)轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。 + +**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 + +## 后果 + +分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制和时钟,但不再显示分支。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖普通 assistant 尾部、纯用户消息尾部,以及后续工具行和被中断推理行对分支操作的抑制。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 21eea20254..5268ea62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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 .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 -2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 +2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf +2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 58960169a2..578e59ec92 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. +The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current. + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index ea2f9030f6..d90124f6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 +本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。 + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index bb58ccefc4..96dd27f393 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic +// completed-turn-tail fork case (zero model calls) and pins the settled +// conversation aria after the footers are focus-revealed — the surface package +// jsdom tests cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,48 @@ const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const MID_TURN_TEXT = 'I will read both files before answering.' +const SECOND_PROMPT = 'Now give the final answer.' + +/** + * Adapt the borrowed recording into response -> tools -> interrupted Think, + * followed by one ordinary completed response. The first response keeps + * copy/clock but is not a legal branch point; the second is the real turn tail. + * @param raw - Recorded seeded-history JSONL. + * @returns A contiguous, closed two-turn fixture. + */ +function completedTailFixture(raw: string): string { + const kept: string[] = [] + for (const line of raw.trimEnd().split('\n')) { + const row = JSON.parse(line) as { + type: string + seq?: number + seq0?: number + data?: { content?: unknown[] } + } + const firstSeq = row.seq ?? row.seq0 + if (firstSeq !== undefined && firstSeq >= 101) break + if (row.type === 'assistant/message' && row.seq === 64) { + const content = row.data?.content + if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT }) + kept.push(JSON.stringify(row)) + } else { + kept.push(line) + } + } + const tail = [ + { type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } }, + { type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }, + { type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }, + { type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n` +} describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold @@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') - const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + const raw = completedTailFixture(await readFile(SEED, 'utf8')) + expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('shows branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,16 +103,17 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User and each turn's last content assistant both - // have copy + branch. + // hover/focus-within). All message rows keep copy, but only the final + // assistant at a completed transcript tail has branch. const copyButtons = page.getByRole('button', { name: 'Copy' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + .toBe(1) await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -89,8 +132,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // Exercise the assistant action specifically; package coverage pins the - // user action separately at its own event seq. + // The sole message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bf67498178..65152bb4de 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,12 +8,14 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I will read both files before answering. +- button "Copy": + - img +- text: 7/25 {{clock}} - button "Read a.txt": - img - img @@ -28,6 +30,9 @@ - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- text: Stopped Now give the final answer. 7/25 {{clock}} +- button "Copy": + - img - paragraph: DONE - button "Copy": - img @@ -42,4 +47,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f20967ee2f..dd36762aeb 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c -README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a +README.md: 91844016ce58e172161343022a1824be91b7ff2f +README.zh.md: 0ea77774c6181858748a8adbc737de7e213be31b diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f956be2238..91844016ce 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before exposing an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 49449c51d8..0ea77774c6 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在暴露操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a4c260a068..7752762eae 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -322,6 +322,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] + /** In-window completed turn number -> its `turn/end` event seq. */ + turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] /** diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 2bcb2ac5fc..dee41b2fe0 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -113,6 +113,11 @@ export class Session implements SessionFace { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private derivedRev = 0 private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null + /** Completed turn boundaries retained from the raw window so presentation + * actions never infer a safe fork point from transcript content alone. */ + private turnEnds = new Map() + private turnEndsRev = 0 + private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 @@ -807,6 +812,8 @@ export class Session implements SessionFace { return } case 'turn/end': { + this.turnEnds.set(event.data.turn, event.seq) + this.turnEndsRev++ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } @@ -897,6 +904,8 @@ export class Session implements SessionFace { this.callsRev++ this.derivedNodes = [] this.derivedRev++ + this.turnEnds = new Map() + this.turnEndsRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -928,6 +937,9 @@ export class Session implements SessionFace { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } } + if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { + this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } + } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } @@ -941,6 +953,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, + turnEnds: this.turnEndsCache.value, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 7f5f793948..6e75593e3a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -426,6 +426,7 @@ describe('live event path', () => { feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() + expect(snapshot.turnEnds.get(1)).toBe(10) const frozen = snapshot.nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) // Ordered inside the flow: after the user message (seq 7), before any later turn. @@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) + expect(after.turnEnds).toBe(before.turnEnds) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 2544870ace..d033669215 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], + turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index aec4bc46bc..597f99f7e3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 65e4542e622713e2cd120906926fc0601ef280bd -README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330 +README.md: e351880066ac00677ab9d22c96ea75e2244a2ecf +README.zh.md: f7c7e3ab6d341f9b3d0866951ab80a18d5968044 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 65e4542e62..e351880066 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -57,8 +57,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. -- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock, plus branch when eligible) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch appears only when that message is also the last transcript node of a completed turn, then forks through that turn, increments the inherited title on the client, and opens the child; a fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock and copy, while branch appears only for a completed turn whose transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4828ad214b..f7c7e3ab6d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -57,8 +57,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 -- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟,符合条件时再显示分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。只有当该消息同时也是已完成轮次的最后一个 transcript 节点时才显示分支;随后 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话;fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;仅当已完成轮次的 transcript 结束于该 user 消息时才显示分支。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..8569051d30 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,9 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized turn-tail content (text) nodes append IconActions once streaming -// ends (`time` is omitted for mid-turn narration); Think / tool-head-only -// nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once streaming ends +// (`time` is omitted for mid-turn narration); their branch action is present +// only when the node is also the completed turn's transcript tail. Think / +// tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -28,7 +29,7 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through the turn containing this finalized message. */ + /** Fork the session through this finalized message's completed turn. */ onFork?: ((seq: number) => void) | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4a8e715bcf..af0e3a1c39 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const turnEnds = useSession(s => s.turnEnds) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -252,6 +253,7 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -401,7 +403,7 @@ export function ChatView({ interrupted={node.interrupted} time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} - onFork={forkAt} + onFork={branchSeqs.has(node.seq) ? forkAt : undefined} t={t} /> ) @@ -416,7 +418,7 @@ export function ChatView({ key={item.key} node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} - onFork={forkAt} + {...branchSeqs.has(node.seq) ? { onFork: forkAt } : {}} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index cd6b0a36ce..81129b94ac 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -17,7 +17,7 @@ export interface MessageIconActionsProps { time: number /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' - /** Fork the session at this message. */ + /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined @@ -50,11 +50,13 @@ export function MessageIconActions({ - - - + {onBranch !== undefined && ( + + + + )} {clock === 'end' ? clockEl : null}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b54604060a..3a27feaef3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,7 +26,7 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork the session through the turn containing this message (user-bubble branch action). */ + /** Fork through this message's completed turn when it is the transcript tail. */ onFork?: (seq: number) => void /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..9bd98fd5a5 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -5,8 +5,8 @@ * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership - * (last content assistant per turn) is derived here too so ChatView and the - * flow share one gate. + * and completed-turn branch points are derived here too so ChatView and the + * flow share their gates. */ import type { AssistantBlock, ConversationNode, ToolResultNode, @@ -47,6 +47,38 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Seq set of message rows that may fork: the last transcript node of a + * completed turn, when that node owns message chrome. A later tool, reasoning, + * error, or other transcript node suppresses the earlier message's branch + * action even though the Host would include the whole turn. + * @param nodes - snapshot nodes in event order. + * @param turnEnds - completed turn boundaries retained from the event window. + * @returns Message seq values whose visible position matches the fork boundary. + */ +export function messageBranchSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { + const result = new Set() + const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) + let nodeIndex = 0 + for (const [turn, endSeq] of boundaries) { + let tail: ConversationNode | undefined + while (nodeIndex < nodes.length) { + const candidate = nodes[nodeIndex] + if (candidate === undefined || candidate.seq > endSeq) break + tail = candidate + nodeIndex++ + } + if (tail?.kind === 'user' + || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + result.add(tail.seq) + } + } + return result +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a6c9d16e61..390a9aeb76 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -454,7 +454,7 @@ export interface ChatViewInjected { /** Last recorded offset, or null when pinned or never recorded. */ read: () => number | null } - /** Fork the session through the turn containing the message at `seq`, then open the child. */ + /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a829f6f04a..964b261b78 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -36,12 +36,14 @@ describe('MessageItem arms', () => { // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() render( , ) expect(screen.getByText('14:24')).toBeTruthy() @@ -50,6 +52,8 @@ describe('MessageItem arms', () => { expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('hello bubble') + fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(1) }) it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { @@ -410,12 +414,15 @@ describe('small branch tails', () => { }) const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() const settled = render( , ) expect(settled.getByText('14:24')).toBeTruthy() @@ -423,6 +430,8 @@ describe('small branch tails', () => { expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() fireEvent.click(settled.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('answer body') + fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(3) settled.unmount() const thinkOnly = render( diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index e1366721c8..6708cdb759 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -67,7 +67,7 @@ function snapshotWith( runningCalls: RunningToolCall[] = [], ): ConversationSnapshot { return { - sessionId: SID, nodes, partial: null, runningCalls, codeDispatches, + sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index a02f8ccb6f..95da35f267 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 04f412fb25..86333eb6ec 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -33,7 +33,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -211,6 +211,24 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('messageBranchSeqs keeps only message rows at completed transcript tails', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const nodes = [ + user(1, 'first'), + assistant(2, 'answer before tools'), + toolResult(3, 'a'), + interruptedThink, + user(6, 'second'), + assistant(7, 'clean tail', 2), + user(10, 'user-only tail'), + ] + const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11]])) + expect([...seqs]).toEqual([7, 10]) + }) }) describe('ChatView', () => { @@ -323,21 +341,38 @@ describe('ChatView', () => { user(5, 'next'), assistant(6, 'second turn', 2), ], + turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render() - // 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free. + // User rows keep copy/clock, while only the two completed assistant tails may branch. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) - expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) }) - it('forks from both user and finalized assistant message actions at their event seq', () => { - const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] }) + it('forks only from a finalized assistant at the completed transcript tail', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer')], + turnEnds: new Map([[1, 3]]), + }) const view = render() const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(2) + expect(buttons).toHaveLength(1) fireEvent.click(buttons[0]!) - fireEvent.click(buttons[1]!) - expect(h.forkAt.mock.calls).toEqual([[1], [2]]) + expect(h.forkAt.mock.calls).toEqual([[2]]) + }) + + it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 1722b9628e..f7de4e45f9 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 4496423efa..8350b318e9 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -24,7 +24,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2072fd2c90..5b2884c01e 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -23,7 +23,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 45ce41c3b8..c91631e374 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -26,7 +26,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index a4eb2e668f..157419fd79 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 6c11119c0a..321d7250fa 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -28,7 +28,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index 700c8c3d75..bf9898cff9 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index b4306d064d..c50c308edf 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index eff655d229..413adf7a86 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 2ae268debd..babefe16f1 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -455,7 +455,7 @@ describe('DetailsPanel Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index a60c16f77c..974df2264a 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, From d9a370e185d52291ae57959fb2ff2e9a373a5ed2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 17:53:38 +0800 Subject: [PATCH 11/14] test(web): refresh completed-turn-tail snapshots --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 2 -- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 2 -- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 2 -- .../web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 2 -- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 2 -- .../tests/snapshots/live-interactions/error-auth.expected.md | 2 -- .../web/tests/snapshots/live-interactions/loading.expected.md | 2 -- apps/web/tests/snapshots/live-interactions/retry.expected.md | 2 -- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 -- .../tests/snapshots/question-composer/answered.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/editing.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 4 ---- apps/web/tests/snapshots/queue-actions/ui.expected.md | 2 -- .../tests/snapshots/seeded-history/command-row.expected.md | 2 -- apps/web/tests/snapshots/seeded-history/ui.expected.md | 2 -- apps/web/tests/snapshots/steering/mid-steer.expected.md | 2 -- apps/web/tests/snapshots/steering/settled.expected.md | 2 -- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 4 ---- apps/web/tests/snapshots/web-search-round/ui.expected.md | 2 -- 20 files changed, 44 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8f4c7e5bf2..239c4d4548 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,8 +7,6 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e4d5ac8426..cc502f2b5f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index facb7b58cc..73817d6c7e 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 5965797c69..d961d20c4f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,8 +7,6 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index d1e4d2bbef..57d2ea37dc 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index fb9337e978..9539eb180f 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index a5dfd08fb5..caee0ad6aa 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6380eaf5c6..a46b0966a9 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 5be3f83247..cd0d50c095 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,8 +8,6 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 28297569ab..e02232a945 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,8 +7,6 @@ - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index b9dee060ac..f51e7b3a1b 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 3a70840713..c9e6229194 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 0a43cee68b..4da58812dd 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img @@ -22,8 +20,6 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 24edb57417..0b70af3f64 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index b916a3add2..e3f8cdc925 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,8 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a168d5e2a3..fee3747f32 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 27b40ef442..857e80e19c 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,8 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 72e28598e5..6eb6600ccd 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,8 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..37588b7756 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,8 +12,6 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img @@ -30,8 +28,6 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 4ff674462e..c0e55a5051 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,8 +7,6 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img From 2178c66b0dec7c1562e8903e1b038920594021f0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 16:06:51 +0800 Subject: [PATCH 12/14] test(tui): refresh queued compaction help snapshot --- .../terminal.expected.txt | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt index 0df46a7018..1d9c7048c6 100644 --- a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt +++ b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=66 base=30 viewport=30 +terminal 100x36 buffer=normal length=68 base=32 viewport=32 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=65 +cursor hidden column=7 viewportRow=35 bufferRow=67 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -73,56 +73,60 @@ buffer style 0-64 dim 37| "/compact — Compact older conversation history " style 0-44 dim -38| "/exit — Exit after the active turn reaches idle " +38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-99 dim +39| "reasoning display " + style 0-16 dim +40| "/exit — Exit after the active turn reaches idle " style 0-46 dim -39| "/help — Show keyboard shortcuts and commands " +41| "/help — Show keyboard shortcuts and commands " style 0-43 dim -40| "/model [[provider/]model] — Show or switch this session's model " +42| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -41| "/palette — Show every color and attribute role this terminal renders " +43| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -42| "/quit — Exit after the active turn reaches idle " +44| "/quit — Exit after the active turn reaches idle " style 0-46 dim -43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -44| "/resume — List this workspace's resumable sessions " +46| "/resume — List this workspace's resumable sessions " style 0-49 dim -45| "/status — Show session diagnostics, system prompt, and registered tools " +47| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -46| "/skill: [instructions] — load a skill into the conversation " +48| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -47| -48| "Context · snapshot-injector" +49| +50| "Context · snapshot-injector" style 0-26 dim -49| "Injected while compaction was running. " +51| "Injected while compaction was running. " style 0-37 dim -50| -51| "… earlier context was compacted … " - style 0-32 dim 52| -53| "You " +53| "… earlier context was compacted … " + style 0-32 dim +54| +55| "You " style 0-2 fg=bright-magenta bold underline -54| "Reply with exactly the word: TWO. No tools. " -55| -56| "Compacted 2 history items (~387 tokens). " - style 0-39 dim +56| "Reply with exactly the word: TWO. No tools. " 57| -58| "Assistant " +58| "Compacted 2 history items (~387 tokens). " + style 0-39 dim +59| +60| "Assistant " style 0-8 fg=bright-magenta bold underline -59| "Reasoning " +61| "Reasoning " style 0-8 dim italic -60| "The user wants me to reply with exactly the word \"TWO\" and no tools. " +62| "The user wants me to reply with exactly the word \"TWO\" and no tools. " style 0-67 dim italic -61| "TWO " -62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " +63| "TWO " +64| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim -63| -64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" +65| +66| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" style 0-49 fg=bright-magenta bold style 52-68 dim style 71-90 dim style 93-99 dim -65| " dsh ◍ " +67| " dsh ◍ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse From c5050f018e467a2f73a8e02f1bb9d976e8004402 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 16:19:49 +0800 Subject: [PATCH 13/14] fix(web): keep ineligible message forks disabled --- ...ions-require-completed-turn-tail.i18n.yaml | 4 +-- ...ork-actions-require-completed-turn-tail.md | 6 ++-- ...-actions-require-completed-turn-tail.zh.md | 6 ++-- apps/web/tests/message-actions.e2e.ts | 19 +++++++---- .../snapshots/bash-abort-row/ui.expected.md | 3 +- .../snapshots/code-mode-round/ui.expected.md | 3 ++ .../cordis-tool-round/ui.expected.md | 3 ++ .../snapshots/fresh-round-trip/ui.expected.md | 3 ++ .../lifecycle-chrome/reloaded.expected.md | 3 ++ .../live-interactions/cancel.expected.md | 3 ++ .../live-interactions/error-auth.expected.md | 3 ++ .../live-interactions/loading.expected.md | 3 ++ .../live-interactions/retry.expected.md | 3 ++ .../snapshots/message-actions/ui.expected.md | 10 +++++- .../plan-review/approved.expected.md | 3 ++ .../question-composer/answered.expected.md | 3 ++ .../queue-actions/collapsed.expected.md | 3 ++ .../queue-actions/editing.expected.md | 3 ++ .../queue-actions/preserved.expected.md | 6 ++++ .../snapshots/queue-actions/ui.expected.md | 3 ++ .../seeded-history/command-row.expected.md | 3 ++ .../snapshots/seeded-history/ui.expected.md | 3 ++ .../snapshots/steering/mid-steer.expected.md | 3 ++ .../snapshots/steering/settled.expected.md | 6 ++++ .../subagent-conversation/ui.expected.md | 6 ++++ .../snapshots/web-search-round/ui.expected.md | 3 ++ packages/client/runtime/README.i18n.yaml | 4 +-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 6 ++-- packages/client/ui-conversation/README.zh.md | 6 ++-- .../src/client/chat/AssistantMarkdown.tsx | 9 ++++-- .../src/client/chat/ChatView.tsx | 6 ++-- .../client/chat/MessageIconActions.module.css | 20 ++++++++++++ .../src/client/chat/MessageIconActions.tsx | 23 ++++++++++--- .../src/client/chat/MessageItem.tsx | 7 ++-- .../src/client/chat/chat-flow.ts | 4 +-- .../ui-conversation/src/client/locales.ts | 2 ++ .../tests/chat-branch-tails.spec.tsx | 24 ++++++++++++++ .../ui-conversation/tests/chat-view.spec.tsx | 32 +++++++++++++------ 41 files changed, 220 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml index 052d983988..9ac8e49fe6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md -2026-08-02-message-fork-actions-require-completed-turn-tail.md: 38a81d9e6966b459ed8d7dd19ba0cbdd790b85b6 -2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 09d2ecc2b110e66348b7cd7c5eb253b4c8cdbb9a +2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md index 38a81d9e69..f2e7fd67b6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -10,7 +10,7 @@ The Web conversation attached branch to the last assistant node with nonempty te ## Decision -`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and exposes branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node suppresses branch on earlier messages. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. @@ -22,6 +22,8 @@ This narrows the message eligibility established by the earlier [Web session for **Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. +**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request. + ## Consequences -A branch icon now denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy and clock but no longer advertises branch. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus suppression by later tool and interrupted reasoning rows. +An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md index 09d2ecc2b1..2c3feeaa3e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -10,7 +10,7 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist ## 决策 -`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才暴露分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作就会被抑制。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 @@ -22,6 +22,8 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist **对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 +**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。 + ## 后果 -分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制和时钟,但不再显示分支。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行对分支操作的抑制。 +启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制、时钟,以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 96dd27f393..aa64816420 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -95,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('shows branch only on the completed transcript tail', async () => { + it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -107,13 +107,20 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). All message rows keep copy, but only the final - // assistant at a completed transcript tail has branch. + // hover/focus-within). Every durable message footer keeps branch visible, + // but only the final assistant at a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBe(1) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll( + () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), + { timeout: 5_000 }, + ).toEqual(['true', 'true', 'true', null]) + await branchButtons.first().focus() + await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) + .toBe('Available only on the last message of a completed turn') await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -132,7 +139,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // The sole message action belongs to the completed second-turn assistant. + // The last message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8bb3c00b66..8f09d36efd 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -7,8 +7,9 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 239c4d4548..c6799a2247 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,6 +7,9 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index cc502f2b5f..fc6d312821 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 73817d6c7e..17df480cd9 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index d961d20c4f..5e0b4f73bb 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,6 +7,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 57d2ea37dc..d18e061a1a 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 9539eb180f..1a4aec678c 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index caee0ad6aa..c50b440f86 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index a46b0966a9..d5df08c05e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 65152bb4de..3d085b0931 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,6 +8,9 @@ - button "Copy": - img - tooltip "Copy" +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img @@ -15,7 +18,9 @@ - paragraph: I will read both files before answering. - button "Copy": - img -- text: 7/25 {{clock}} +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn 7/25 {{clock}} - button "Read a.txt": - img - img @@ -33,6 +38,9 @@ - text: Stopped Now give the final answer. 7/25 {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index cd0d50c095..108ca47986 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,6 +8,9 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index e02232a945..b5ed63a63e 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,6 +7,9 @@ - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index f51e7b3a1b..bd44e33ad0 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 4d0e5af1f1..8df2ea2940 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a166375107..74de289ee9 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -20,6 +23,9 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index e0c648dd1f..7afdf3c8b4 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index e3f8cdc925..b4eddfc24e 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,6 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index fee3747f32..4643c60f3b 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index d95a55f029..5b316f4efb 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,6 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d4d1098ead..4d8d35a085 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,6 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -22,6 +25,9 @@ - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index d2bafefdcb..9497cc36d3 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,6 +12,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -28,6 +31,9 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index c0e55a5051..97ca405141 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,6 +7,9 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 280f1e8108..c3850804ff 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: be4463343a04f7287cfba8c48bdd71fa4d2cade4 -README.zh.md: cec93ef091a0dec0a10c1de1b04cf86966b972bd +README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 +README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index be4463343a..89e58f967f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before exposing an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cec93ef091..960e2fceed 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在暴露操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2fe596c31b..09e17a4371 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 4f06b407a6c2288be2c58ad01655fd37e7e368ae -README.zh.md: db0f31cc506f0d3d600296d5d00ae70e12df4fc8 +README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 +README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4f06b407a6..e1dbe7d4d5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy from the durable node, exposes Fork only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock, plus branch when eligible) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch appears only when that message is also the last transcript node of a completed turn, then forks through that turn, increments the inherited title on the client, and opens the child; a fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). -- **Sent user messages cannot be edited** — user bubbles retain clock and copy, while branch appears only for a completed turn whose transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index db0f31cc50..fd82ad65f8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作,仅当该节点是已完成轮次的 transcript 尾部时才显示 fork,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟,符合条件时再显示分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。只有当该消息同时也是已完成轮次的最后一个 transcript 节点时才显示分支;随后 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话;fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 -- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;仅当已完成轮次的 transcript 结束于该 user 消息时才显示分支。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 45104002f6..3f87e40e8b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -5,7 +5,7 @@ // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. // Finalized content (text) nodes append IconActions once streaming ends -// (`time` is omitted for mid-turn narration); their branch action is present +// (`time` is omitted for mid-turn narration); their branch action is enabled // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. @@ -29,8 +29,10 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through this finalized message's completed turn. */ + /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -77,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -121,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ time={time} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 021d3f0e4a..076da6c6ab 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -414,7 +414,8 @@ export function ChatView({ interrupted={node.interrupted} time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} - onFork={branchSeqs.has(node.seq) ? forkAt : undefined} + onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) @@ -429,7 +430,8 @@ export function ChatView({ key={item.key} node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} - {...branchSeqs.has(node.seq) ? { onFork: forkAt } : {}} + onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index b247b7e2bf..8a43b42f83 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -43,3 +43,23 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } + +/* Unavailable stays focusable and hoverable so Tooltip can explain why. */ +.action[data-unavailable] { + cursor: default; + opacity: 0.4; +} + +.action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 6b862a9021..6d77d4db68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback } from 'react' +import { useCallback, useId } from 'react' import { IconBranchOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -19,6 +19,8 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined + /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ + branchUnavailable?: boolean | undefined /** Additional branch visibility gate for transient message chrome; defaults to true. */ showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ @@ -33,9 +35,10 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, showBranch = true, className, t, + text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() + const reasonId = useId() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -53,12 +56,24 @@ export function MessageIconActions({ {showBranch && onBranch !== undefined && ( - - )} + {showBranch && onBranch !== undefined && branchUnavailable && ( + {t('message.branchUnavailable')} + )} {clock === 'end' ? clockEl : null} ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 7ea870e326..df04ac19c2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,8 +26,10 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork through this message's completed turn when it is the transcript tail. */ + /** Fork through this message's completed turn when eligible. */ onFork?: (seq: number) => void + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, t, + node, retryActive = false, onFork, forkUnavailable = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({ time={node.time} clock="start" onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index cabcba23dd..9de9876d89 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -50,8 +50,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon /** * Seq set of message rows that may fork: the last transcript node of a * completed turn, when that node owns message chrome. A later tool, reasoning, - * error, or other transcript node suppresses the earlier message's branch - * action even though the Host would include the whole turn. + * error, or other transcript node leaves the earlier message's branch action + * unavailable because the Host would include the whole turn. * @param nodes - snapshot nodes in event order. * @param turnEnds - completed turn boundaries retained from the event window. * @returns Message seq values whose visible position matches the fork boundary. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 87252fbc77..7bc01b4d95 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -166,6 +167,7 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 92a804ebfd..89a09ca28d 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -78,6 +78,30 @@ describe('MessageItem arms', () => { expect(exec).toHaveBeenCalledWith('copy') }) + it('keeps an unavailable branch focusable and explains why without sending a fork', () => { + const onFork = vi.fn() + render( + , + ) + const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement + expect(branch.disabled).toBe(false) + expect(branch.getAttribute('aria-disabled')).toBe('true') + const reasonId = branch.getAttribute('aria-describedby') + expect(reasonId).not.toBeNull() + expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') + fireEvent.click(branch) + expect(onFork).not.toHaveBeenCalled() + fireEvent.focus(branch) + expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') + }) + it('user copy stays quiet when execCommand throws or is absent', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 5a06f588ee..a041aca214 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -325,14 +325,19 @@ describe('ChatView', () => { expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) - expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement + const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) + expect(unavailable.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(unavailable) + expect(h.forkAt).not.toHaveBeenCalled() act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(branchButtons).toHaveLength(1) - fireEvent.click(branchButtons[0]!) + expect(branchButtons).toHaveLength(2) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) + fireEvent.click(branchButtons[1]!) expect(h.forkAt).toHaveBeenCalledWith(2) }) @@ -435,24 +440,28 @@ describe('ChatView', () => { turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render() - // User rows keep copy/clock, while only the two completed assistant tails may branch. + // Every message footer keeps branch visible; only completed assistant tails enable it. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) - expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(branchButtons).toHaveLength(4) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null]) }) - it('forks only from a finalized assistant at the completed transcript tail', () => { + it('enables fork only on the finalized assistant at the completed transcript tail', () => { const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')], turnEnds: new Map([[1, 3]]), }) const view = render() const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(1) + expect(buttons).toHaveLength(2) + expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => { + it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -463,7 +472,12 @@ describe('ChatView', () => { }) const view = render() expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) - expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(buttons).toHaveLength(2) + expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true) + fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) + expect(h.forkAt).not.toHaveBeenCalled() }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { From 886c39d30200ed36dc8196bd14a673292bec038d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 16:21:02 +0800 Subject: [PATCH 14/14] test(web): refresh Markdown image snapshot --- .../snapshots/markdown-images/ui.expected.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index cd33a09eb9..76e01397c2 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -5,27 +5,24 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": - - img -- button "编辑": +- button "Branch into a new conversation": - img - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" - paragraph: Local test image - paragraph: REMOTE_IMAGE_DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img