From d9cb19f276ea2403decdbb73092388f0d253a64e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 17:34:03 +0800 Subject: [PATCH 1/4] feat(ui): drop the slash and the argument echo from the command row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web command row renders `title · summary` from one logged command lifecycle pair, and the two halves were written without knowing about each other: the title was the dispatched line rebuilt from `command/run` and the summary was `command/done`'s verbatim text, so every Access-chip pick read `/permission workspace-write · Permission preset: workspace-write.` — the command name twice and its argument twice. The title is now the bare command name (no `/`, no arguments — the summary already says what the command did), and a command handler's settlement text never repeats the command's own name, so `/permission` returns `preset workspace-write`. The row reads `permission · preset workspace-write`, and the TUI notice still names the preset that now applies. The log is unchanged: `command/run` keeps its structured name/args split for a richer registered row. --- ...-07-30-command-row-copy-contract.i18n.yaml | 6 +++ .../2026-07-30-command-row-copy-contract.md | 31 +++++++++++++ ...2026-07-30-command-row-copy-contract.zh.md | 31 +++++++++++++ apps/web/tests/seeded-history.e2e.ts | 28 +++++++++++- .../seeded-history/command-row.expected.md | 45 +++++++++++++++++++ .../client/connection/src/client/fixture.ts | 4 +- packages/client/runtime/tests/session.spec.ts | 2 +- .../src/client/chat/GenericCommandCard.tsx | 13 +++--- .../ui-conversation/tests/chat-view.spec.tsx | 10 +++-- packages/ui/permission/src/index.ts | 7 ++- .../ui/permission/tests/projection.spec.ts | 4 +- 11 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md create mode 100644 apps/web/tests/snapshots/seeded-history/command-row.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml new file mode 100644 index 0000000000..f78521f13b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.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/architecture/2026-07-30-command-row-copy-contract.md +2026-07-30-command-row-copy-contract.md: 984f2a94a4aaa23b3892f1f62a5374ab74b38723 +2026-07-30-command-row-copy-contract.zh.md: 3dd99927a346c29724a67193bb02d87e0b0c7e5c diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md new file mode 100644 index 0000000000..984f2a94a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md @@ -0,0 +1,31 @@ +# Agent Note: Command row copy is split between the row and the handler + +Status: implemented + +English | [中文](2026-07-30-command-row-copy-contract.zh.md) + +## Problem + +The web command row renders `title · summary` from one logged [command lifecycle pair](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md): the title was the dispatched line rebuilt from `command/run` (`/permission workspace-write`) and the summary was `command/done`'s verbatim `text` (`Permission preset: workspace-write.`). Both halves were written without knowing about the other, so the row said the command name twice and its argument twice — the single worst case being the row a user gets for every Access-chip pick. + +## Decision + +The row's two halves have disjoint jobs, and each side is written to its own half alone. + +The row title is the bare command name — no `/`, no arguments. The `/` belongs to the composer's input grammar, not to a settled record, and the argument is not the row's to report: the summary already says what the command did. `GenericCommandCard` keeps the `命令` fallback for a cross-window node whose `command/run` page fell out of the client's window. + +A command handler's settlement `text` therefore never repeats the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write` and, bare, `current preset workspace-write (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies. + +The log is unchanged: `command/run` keeps the structured `name`/`args` split, so a richer registered command row can still render arguments from the same node without a second data channel. + +## Alternatives considered + +**Keep the dispatched line as the title and only shorten the settlement text.** The argument would still appear on both sides of the separator (`permission workspace-write · preset workspace-write`), which is the repetition complained about. + +**Drop the settlement text from the collapsed row instead of the arguments.** It inverts the row's value: the outcome is what a durable record is for, and an error text would then have nowhere to land. + +**Have the row strip a leading command name from the settlement text.** Presentation would silently rewrite handler-authored text, and every handler that phrased its outcome differently would defeat the heuristic. + +## Consequences + +Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-repetition rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host. diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md new file mode 100644 index 0000000000..3dd99927a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Command row copy is split between the row and the handler + +Status: implemented + +[English](2026-07-30-command-row-copy-contract.md) | 中文 + +## Problem + +Web 命令行由一对落库的[命令生命周期事件](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)渲染出 `标题 · 摘要`:标题是由 `command/run` 重建的分派命令行(`/permission workspace-write`),摘要是 `command/done` 的原样 `text`(`Permission preset: workspace-write.`)。两半各自成文、互不知情,于是一行里命令名出现两次、参数也出现两次——最糟的一例正是用户每次用 Access chip 切换权限时得到的那一行。 + +## Decision + +命令行两半的职责互不重叠,各自只按自己那一半来写。 + +行标题就是裸命令名——没有 `/`,也没有参数。`/` 属于编辑器的输入语法,不属于一条已落定的记录;参数也不该由这一行来报告:摘要已经说清了这条命令做了什么。对于 `command/run` 那一页已滑出客户端窗口的跨窗口节点,`GenericCommandCard` 仍保留 `命令` 兜底标题。 + +因此,命令 handler 的落定 `text` 绝不重复命令自身的名字——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`。作为一行读是 `permission · preset workspace-write`;作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。 + +日志本身未变:`command/run` 保留结构化的 `name`/`args` 拆分,因此更丰富的已注册命令行仍可从同一个节点渲染参数,无需第二条数据通道。 + +## Alternatives considered + +**保留分派命令行作标题,只缩短落定文案。** 参数仍会出现在分隔点两侧(`permission workspace-write · preset workspace-write`),而这正是被指出的重复。 + +**从折叠行中去掉落定文案,而不是去掉参数。** 这颠倒了这一行的价值:持久记录存在的意义就是结果,而错误文案将无处落脚。 + +**由这一行从落定文案里剥掉开头的命令名。** 呈现层会悄悄改写 handler 写就的文案,而任何换一种措辞表达结果的 handler 都会让这套启发式失效。 + +## Consequences + +每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不重复"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index f998c974b8..d9ead94e8c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -3,7 +3,9 @@ // else covers: sidebar cold listing, the implicit resume/attach inside the // history RPC, history-page tool views, and the client fold of historical // events — with ZERO model calls in replay (no replay fixture; a stray stream -// fails loud on the open llm seam). The seed is a recorded fixture under the +// fails loud on the open llm seam). The cold session also carries the one +// keyless command-row surface: an Access-chip pick runs `/permission` on the +// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) // and harvests seed.jsonl; replay/refresh seed it cold and only render. @@ -22,6 +24,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) +// The command-row golden: the same conversation after one /permission switch, +// which is the only surface that shows a settled command row's copy. +const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -147,11 +152,30 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) + // The Access chip submits `/permission ` — a host command with no + // model call, so the settled row renders keylessly over this cold history. + // The row copy is the assertion: `permission · preset workspace-write`, + // where neither half repeats the other (the dispatched `/` and its + // argument stay out of the title, and the settlement text never restates + // the command's own name). + await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click() + await page.getByRole('menuitem', { name: 'Workspace Write' }).click() + await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) + await expect.poll(() => page.getByText('preset workspace-write', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await page.getByText('permission', { exact: true }).count()).toBe(1) + expect(await page.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) + }, 60_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md new file mode 100644 index 0000000000..147e742319 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -0,0 +1,45 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- 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. {{clock}}" +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - 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. +- img +- text: Read +- button "a.txt" +- img +- text: Read +- button "b.txt" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - 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. +- paragraph: DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- img +- text: permission preset workspace-write +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- 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 · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..2278e4545f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1331,14 +1331,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const spec = PERMISSION_PRESETS[preset] if (preset === '') { const current = permissionSelectOf(logOf(id)).currentValue - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else if (spec === undefined) { append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else { if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) append(id, { type: 'approval/policy', data: { policy: spec.approval } }) - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } }) } return ok(request, { matched: true as const, commandId }) } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c80f046ceb..7dfd9efa54 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -135,7 +135,7 @@ describe('live event path', () => { expect(session.getSnapshot().composerPhase).toBe('blank') const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) - feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.')) + feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) expect(snapshot.composerPhase).toBe('blank') diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..4b42a94c72 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ // GenericCommandCard: the default command row — a stripped-down -// GenericToolCard rendering the dispatched command line and the settlement -// text. Supplied by the chat view as the keyed commandview slot's render-site +// GenericToolCard rendering the command name and its settlement text. +// Supplied by the chat view as the keyed commandview slot's render-site // fallback (an unregistered command name lands here); registrants may compose // it as a base, feeding the same owner payload through. @@ -20,10 +20,11 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) { const summary = node.outcome === null ? '执行中…' : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成') - // Display line rebuilt from the structured payload (args carries its own - // separator whitespace verbatim); a cross-window node whose run page fell - // out of the window has neither. - const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}` + // Title is the bare command name: the row already reads `name · outcome`, + // and the dispatched line's own `/` and arguments only restate what the + // settlement text says (`permission · preset workspace-write`). A + // cross-window node whose run page fell out of the window has no name. + const title = node.name ?? '命令' return ( { name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, ...over, }) - // Settled success: the command line is the title, the outcome text the summary. - const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] }) + // Settled success: the bare command name is the title, the outcome text + // the summary — neither the dispatched `/` nor its arguments reach the row + // (the settlement text already says what the command did). + const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] }) const view = render() - expect(view.getByText('/plan')).toBeTruthy() + expect(view.getByText('plan')).toBeTruthy() + expect(view.queryByText('/plan')).toBeNull() + expect(view.queryByText('/plan now')).toBeNull() expect(view.getByText('已进入 plan mode')).toBeTruthy() // Error outcome flips the row state; a text-less error gets the default copy. diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 597d17199a..b9f85ff60d 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -211,16 +211,19 @@ export class PermissionService extends Service { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' }, + // The settlement text never repeats the command's own name: a surface + // that renders `name · text` (the web command row) would otherwise + // read `permission · Permission preset: workspace-write.` handler: ({ agent, rawInput }) => { const name = rawInput.trim() if (name === '') { - return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` } + return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` } } if (!this.names.includes(name)) { return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` } } this.set(agent.session, name) - return { kind: 'success', text: `Permission preset: ${name}.` } + return { kind: 'success', text: `preset ${name}` } }, }) }) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 2046adb6a1..0b057ce91c 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -89,7 +89,7 @@ describe('/permission command', () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) - expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' }) + expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) expect(ctx.permission.current(session.events)).toBe('danger-full-access') const run = session.events.find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) @@ -101,7 +101,7 @@ describe('/permission command', () => { const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', - text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.', + text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) }) From f4111a370160b890faede3d3b49eeea649b24da5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 18:07:35 +0800 Subject: [PATCH 2/4] fix(ui): drop the caption from the /permission error text too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round: the no-caption rule the Agent Note states applied only to the success texts, leaving `permission · unknown permission preset "bogus" (…)`. The error text now reads `unknown preset "bogus" (…)` and its exact wording is pinned; the fixture mirror drops `JSON.stringify` for the host's own quoting so the two cannot drift on a quoted argument. The Note now states the line it draws: the rule bans a caption for the command's own value, not the vocabulary, so `/plan`'s `Plan mode off.` and `/goal`'s `Goal cleared.` conform as written — recorded with the broader name-ban as a rejected alternative. The web row assertions are scoped to the row so unrelated page text reading `permission` cannot satisfy them. --- .../2026-07-30-command-row-copy-contract.i18n.yaml | 4 ++-- .../architecture/2026-07-30-command-row-copy-contract.md | 8 ++++++-- .../2026-07-30-command-row-copy-contract.zh.md | 8 ++++++-- apps/web/tests/seeded-history.e2e.ts | 9 ++++++--- packages/client/connection/src/client/fixture.ts | 2 +- packages/ui/permission/src/index.ts | 8 ++++---- packages/ui/permission/tests/projection.spec.ts | 8 +++++++- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml index f78521f13b..330a279b35 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.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-30-command-row-copy-contract.md -2026-07-30-command-row-copy-contract.md: 984f2a94a4aaa23b3892f1f62a5374ab74b38723 -2026-07-30-command-row-copy-contract.zh.md: 3dd99927a346c29724a67193bb02d87e0b0c7e5c +2026-07-30-command-row-copy-contract.md: f6d5199389b3907780c501894e2861e6add85e77 +2026-07-30-command-row-copy-contract.zh.md: 4afaf31640c07e88765060681739f262f322769e diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md index 984f2a94a4..f6d5199389 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md @@ -14,7 +14,9 @@ The row's two halves have disjoint jobs, and each side is written to its own hal The row title is the bare command name — no `/`, no arguments. The `/` belongs to the composer's input grammar, not to a settled record, and the argument is not the row's to report: the summary already says what the command did. `GenericCommandCard` keeps the `命令` fallback for a cross-window node whose `command/run` page fell out of the client's window. -A command handler's settlement `text` therefore never repeats the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write` and, bare, `current preset workspace-write (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies. +A command handler's settlement `text` therefore never labels its value with the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write`, bare `current preset workspace-write (available: …)`, and for a bad argument `unknown preset "bogus" (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies. + +The rule bans the *label*, not the vocabulary. `Permission preset: workspace-write.` lost because `Permission preset:` is a caption for a value whose caption is already the title. A domain noun that happens to contain the command's name is not a caption and stays: `/plan` keeps `Plan mode off.` and `Plan mode on. Use /plan off to leave.` (`plan · Plan mode off.` names the mode, and the tail is an instruction, not an echo), and `/goal` keeps `Goal cleared.`. A handler that finds itself writing ` :` in front of its own value is the case this rule catches. The log is unchanged: `command/run` keeps the structured `name`/`args` split, so a richer registered command row can still render arguments from the same node without a second data channel. @@ -26,6 +28,8 @@ The log is unchanged: `command/run` keeps the structured `name`/`args` split, so **Have the row strip a leading command name from the settlement text.** Presentation would silently rewrite handler-authored text, and every handler that phrased its outcome differently would defeat the heuristic. +**Ban the command's name from its settlement text outright, rewriting `/plan` and `/goal` to match.** The broader ban costs more than it buys: `Plan mode off.` and `Goal cleared.` are the clearest sentences those outcomes have, in the row and as standalone TUI notices both, and the shortenings that satisfy a name ban (`off.`, `cleared.`) read as fragments. Captions are the redundancy worth removing. + ## Consequences -Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-repetition rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host. +Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-caption rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host. diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md index 3dd99927a3..4afaf31640 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md @@ -14,7 +14,9 @@ Web 命令行由一对落库的[命令生命周期事件](../../proposed/archite 行标题就是裸命令名——没有 `/`,也没有参数。`/` 属于编辑器的输入语法,不属于一条已落定的记录;参数也不该由这一行来报告:摘要已经说清了这条命令做了什么。对于 `command/run` 那一页已滑出客户端窗口的跨窗口节点,`GenericCommandCard` 仍保留 `命令` 兜底标题。 -因此,命令 handler 的落定 `text` 绝不重复命令自身的名字——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`。作为一行读是 `permission · preset workspace-write`;作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。 +因此,命令 handler 的落定 `text` 绝不用命令自身的名字给自己的值加标签——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`,参数非法时返回 `unknown preset "bogus" (available: …)`。作为一行读是 `permission · preset workspace-write`;作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。 + +这条规则禁的是*标签*,不是用词。`Permission preset: workspace-write.` 之所以出局,是因为 `Permission preset:` 是给一个值加的题头,而这个题头正是标题本身。恰好含有命令名的领域名词不是题头,因此保留:`/plan` 仍返回 `Plan mode off.` 与 `Plan mode on. Use /plan off to leave.`(`plan · Plan mode off.` 说的是那个模式,句尾是一条指引,不是回声),`/goal` 仍返回 `Goal cleared.`。真正被这条规则拦下的,是 handler 在自己的值前面写出 `<命令名> <名词>:` 的那一类。 日志本身未变:`command/run` 保留结构化的 `name`/`args` 拆分,因此更丰富的已注册命令行仍可从同一个节点渲染参数,无需第二条数据通道。 @@ -26,6 +28,8 @@ Web 命令行由一对落库的[命令生命周期事件](../../proposed/archite **由这一行从落定文案里剥掉开头的命令名。** 呈现层会悄悄改写 handler 写就的文案,而任何换一种措辞表达结果的 handler 都会让这套启发式失效。 +**彻底禁止命令名出现在自己的落定文案里,并把 `/plan`、`/goal` 一并改写。** 这种更宽的禁令代价大于收益:无论在行上还是作为独立的 TUI 通知,`Plan mode off.` 与 `Goal cleared.` 都是这些结果最清楚的句子,而满足"禁名字"所需的缩写(`off.`、`cleared.`)读起来只是残句。值得去掉的冗余是题头。 + ## Consequences -每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不重复"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。 +每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不加题头"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 39d450f55c..44e0c67ea5 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -163,9 +163,12 @@ describe('web e2e: seeded history renders through cold resume', () => { await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click() await page.getByRole('menuitem', { name: 'Workspace Write' }).click() await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) - await expect.poll(() => page.getByText('preset workspace-write', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - expect(await page.getByText('permission', { exact: true }).count()).toBe(1) - expect(await page.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) + // Scoped to the row itself, so unrelated page text that happens to read + // `permission` (a future resident slash menu) cannot satisfy or break it. + const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' }) + await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) + expect(await row.getByText('permission', { exact: true }).count()).toBe(1) + expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 2278e4545f..7a1e91989e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1333,7 +1333,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const current = permissionSelectOf(logOf(id)).currentValue append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else if (spec === undefined) { - append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else { if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index b9f85ff60d..5c18a2b560 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -211,16 +211,16 @@ export class PermissionService extends Service { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' }, - // The settlement text never repeats the command's own name: a surface - // that renders `name · text` (the web command row) would otherwise - // read `permission · Permission preset: workspace-write.` + // No settlement text labels its value with this command's own name: a + // surface that renders `name · text` (the web command row) would + // otherwise read `permission · Permission preset: workspace-write.` handler: ({ agent, rawInput }) => { const name = rawInput.trim() if (name === '') { return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` } } if (!this.names.includes(name)) { - return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` } + return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } } this.set(agent.session, name) return { kind: 'success', text: `preset ${name}` } diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 0b057ce91c..1649fe7077 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -110,7 +110,13 @@ describe('/permission command', () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) - expect(execution?.result).toMatchObject({ kind: 'error' }) + // The error text carries the same no-self-labelling rule as the success + // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission + // preset`, which the row's own title already says. + expect(execution?.result).toEqual({ + kind: 'error', + text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', + }) expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) }) }) From f93b134c44a92dab71391ae8ac0f6d5564c38b99 Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 31 Jul 2026 00:04:45 +0800 Subject: [PATCH 3/4] fix: tests --- .../tests/snapshots/seeded-history/command-row.expected.md | 4 ++++ 1 file changed, 4 insertions(+) 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 147e742319..87ffd9fc55 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,6 +31,10 @@ - button "在新对话中分支": - img - text: {{clock}} +- button "上下文注入": + - img + - img + - text: 上下文注入 - img - text: permission preset workspace-write - textbox "Message the agent" From f26a12ba3fa8690809268adff17995c5451da9af Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:40:04 +0800 Subject: [PATCH 4/4] test(web): re-record plan-review golden for slash-free command row The plan-review golden landed on master (63c477f14) recorded against the old command-row rendering; this branch drops the slash and the argument echo, so the approved-state transcript line changes accordingly. --- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index aca0bc31bb..cb905c0b8b 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "/plan 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. 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}}" +- 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 "复制": - img - button "在新对话中分支":