# 终端等待 banner + 跳过等待 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** agent 阻塞在 `terminal_wait_for` 时,对应终端卡片顶部显示「Agent 正在等待 {needle}」banner(右侧「跳过等待」按钮),tab 标题加 ⏳ 徽章;跳过后工具返回新增的 `{kind:'skipped'}`。 **Architecture:** 等待状态登记在 `AgentPtyRegistry` 的 handle 上,随现有 `/sidebar/ws/agent-terminals` 推送快照(`waiting` 字段)自然下发;跳过走新增 `/sidebar/api/agent-pty.skip-wait` 路由。客户端把推送镜像进 `state.agentWaits`,banner 组件与 tab 徽章从 store 读。 **Tech Stack:** TypeScript / React 18 / vitest(node 环境 pty 测试 + jsdom Sidebar 壳测试)/ ws 推送 / CSS Modules(皮肤令牌)。 **Spec:** `docs/plans/2026-09-09-terminal-wait-banner-design.md`(与本计划同分支,执行者两份都读)。 ## Global Constraints - 分支:`feat/wait-banner`(已含设计文档提交 `4a6a77a`);所有代码改动走该分支 → PR,禁止直推 main(分支保护)。 - 皮肤契约:新增 CSS 只允许 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌;本项目复用现成 warn 对 `--dsw-alias-state-warn-label` / `--dsw-alias-state-warn-tertiary`(仓库 CSS 无 state-info 令牌,已核实)。 - i18n:新增 zh key 必须同步 en 词典与 `src/client/locales-ja.ts` 的 ja 翻译。 - client bundle 纯度门:新文件只准 import 本插件模块(locales / css / api),禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`。 - 不改 `src/client/service.ts` / `docs/external-plugin-guide.md`(⏳ 徽章走 shell 层特例,不动 TabDescriptor API)。 - 上游测试(含 win32 已知 27 个非回归失败)一律不改。 - 每个任务用 `pnpm exec vitest run ` 验证;最终任务必须 `pnpm lint` + `pnpm typecheck` + `pnpm test` + `pnpm build` 全绿才 push(CI 曾因漏 lint 挂过)。 - 提交信息风格:`feat(terminal): ...` / `test(...)`,仓库惯例小写 scope。 --- ### Task 1: Registry 等待登记 + `skipped` 结果 + `waiting` 快照 **Files:** - Modify: `src/agent-pty.ts` - Test: `tests/agent-pty.spec.ts` **Interfaces:** - Consumes: 现有 `AgentPtyRegistry.waitFor` / `notify` / `subscribe` / `snapshotOf`。 - Produces: - `type AgentTerminalActiveWait = { needle: string; since: number; skipped: boolean }` - `AgentTerminalHandle.waits: AgentTerminalActiveWait[]`(create 时初始化为 `[]`) - `AgentTerminalSnapshot.waiting?: { needle: string; since: number }`(最新一条活动等待) - `AgentTerminalWaitResult` 新增 `{ kind: 'skipped'; needle: string }` 变体 - `registry.skipWait(uuid: string): number`(标记全部活动等待,返回条数;未知 uuid 抛 not-found) - [ ] **Step 1: 写失败测试(4 个新用例,加在 `describe('AgentPtyRegistry')` 内、现有 `waitFor` 用例之后)** 在 `tests/agent-pty.spec.ts` 的 import 块不需要新增(`AgentPtyRegistry` / `snapshotOf` 已导入)。追加用例: ```ts it('waitFor returns skipped when the user skips from the sidebar', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'skip-ready') // waitFor registers its record synchronously (before the first poll // await), so the skip can fire immediately after the call. const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000) expect(await registry.skipWait(uuid)).toBe(1) const result = await waitPromise expect(result.kind).toBe('skipped') if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ') // Idempotent: nothing left to skip once the wait resolved. expect(registry.skipWait(uuid)).toBe(0) } finally { registry.disposeAll() } }) it('snapshot exposes waiting while a wait is active and clears after it ends', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'snap-ready') const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000) // Registration happens synchronously before waitFor's first await. expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9') expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number') expect(registry.skipWait(uuid)).toBe(1) const result = await waitPromise expect(result.kind).toBe('skipped') expect(registry.list('s1')[0]?.waiting).toBeUndefined() } finally { registry.disposeAll() } }) it('fires change listeners when a wait starts and ends', async () => { const registry = new AgentPtyRegistry(testShell()) try { const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24) await waitForTranscript(registry, uuid, 'notify-ready') let changes = 0 const unsubscribe = registry.subscribe(() => { changes += 1 }) const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000) const afterStart = changes expect(afterStart).toBeGreaterThanOrEqual(1) expect(await registry.skipWait(uuid)).toBe(1) expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' }) expect(changes).toBeGreaterThan(afterStart) unsubscribe() } finally { registry.disposeAll() } }) it('skipWait rejects an unknown uuid with not-found', () => { const registry = new AgentPtyRegistry(testShell()) try { expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/) } finally { registry.disposeAll() } }) ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm exec vitest run tests/agent-pty.spec.ts` Expected: FAIL — `waiting` 为 undefined、`skipWait` 不存在、skipped 分支不存在(TS 编译错误或断言失败)。 - [ ] **Step 3: 实现 `src/agent-pty.ts`** (a) 在 `AgentTerminalReadResult` 之后、`AgentTerminalWaitResult` 之前新增: ```ts /** One active wait_for registration on a handle (banner + skip bookkeeping). */ export interface AgentTerminalActiveWait { /** The needle being awaited (shown on the sidebar wait banner). */ needle: string /** Epoch ms when the wait registered (age display / debugging). */ since: number /** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */ skipped: boolean } ``` (b) `AgentTerminalWaitResult` union 末尾追加变体: ```ts | { /** The user skipped the wait from the sidebar banner. */ kind: 'skipped' /** The needle that was awaited. */ needle: string } ``` (c) `AgentTerminalSnapshot` 追加字段(`exitSignal` 之后): ```ts /** * The model's active `terminal_wait_for` on this terminal (the sidebar * renders the wait banner from it). Present only while a wait is * registered; carries the LATEST wait when several overlap. */ waiting?: { needle: string; since: number } ``` (d) `AgentTerminalHandle` 追加字段(`exitSignal` 之后): ```ts /** Active wait_for registrations (skip bookkeeping; empty while idle). */ waits: AgentTerminalActiveWait[] ``` (e) `create()` 的 handle 字面量中 `exited: false,` 之后加 `waits: [],`。 (f) `snapshotOf` 在 `return out` 前追加: ```ts const active = handle.waits.at(-1) if (active !== undefined) out.waiting = { needle: active.needle, since: active.since } ``` (g) 新方法(放在 `waitFor` 之后): ```ts /** * Mark every active wait on one terminal as skipped (the sidebar banner's * skip button). Each waiting poll loop observes its record's flag within * one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing * is waiting (a stale banner racing a wait that already resolved). * @returns the number of waits that transitioned to skipped. */ skipWait(uuid: string): number { const handle = this.expect(uuid) let count = 0 for (const record of handle.waits) { if (!record.skipped) { record.skipped = true count += 1 } } return count } ``` (h) 重写 `waitFor` 中段(`const firstHit = ...` 快速路径之后到方法结尾),登记记录 + 轮询检查 + `finally` 清理: ```ts const firstHit = locateNeedle(handle.transcript, needle, re) if (firstHit !== undefined) { return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start } } // Register the active wait so the sidebar can show the wait banner (the // snapshot's `waiting` field rides the agent-terminals push). Registered // only after the fast paths: a wait that resolves instantly never // flashes the banner. The notify() makes every push subscriber (the // sidebar view) converge on the new waiting state immediately. const record: AgentTerminalActiveWait = { needle, since: start, skipped: false } handle.waits.push(record) this.notify() try { while (true) { if (signal?.aborted) signal.throwIfAborted() if (handle.exited) { return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } } if (record.skipped) { return { kind: 'skipped', needle } } const hit = locateNeedle(handle.transcript, needle, re) if (hit !== undefined) { return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start } } if (Date.now() >= deadline) { return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } } await new Promise(resolve => { const t = setTimeout(resolve, 50) // Allow the Node process to exit even if the timer is pending. if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref() }) } } finally { const index = handle.waits.indexOf(record) if (index !== -1) handle.waits.splice(index, 1) this.notify() } ``` (i) `waitFor` 的 JSDoc:`@returns one of `found` / `timeout` / `exited`.` 改为 `@returns one of `found` / `timeout` / `exited` / `skipped`.`;并在 `@param signal` 行后补一行 `* A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`.` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm exec vitest run tests/agent-pty.spec.ts` Expected: PASS(全部用例,含既有 425 行文件内的用例)。 - [ ] **Step 5: Commit** ```bash git add src/agent-pty.ts tests/agent-pty.spec.ts git commit -m "feat(terminal): registry 支持等待登记、skipWait 与 skipped 结果" ``` --- ### Task 2: 工具层 `skipped` 分支 + 宿主 `agent-pty.skip-wait` 路由 **Files:** - Modify: `src/tools.ts`(terminal_wait_for 定义,约 287-377 行) - Modify: `src/index.ts`(buildApi 表,`'agent-pty.close'` 之后,约 545 行) - Test: `tests/tools.spec.ts` **Interfaces:** - Consumes: Task 1 的 `AgentTerminalWaitResult` skipped 变体、`registry.skipWait(uuid): number`。 - Produces: `/sidebar/api/agent-pty.skip-wait` 路由(payload `{uuid}` → `{ok:true, skipped:number}`);工具 output schema 第四分支;Task 4 的客户端 `api.agentSkipWait` 调它。 - [ ] **Step 1: 写失败测试** `tests/tools.spec.ts`:(a) `FakeRegistry` 增加注入口 —— 在 `readonly terminals = ...` 行后加属性,并把 `waitFor` 改为: ```ts /** When set, waitFor resolves with the skipped shape instead of the found default. */ nextWaitResult: { kind: 'skipped' } | undefined waitFor(_uuid: string, needle: string): Promise< { kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number } | { kind: 'skipped'; needle: string } > { // Mirrors the registry's empty-needle rejection (the tool layer no // longer duplicates this check). if (needle === '') return Promise.reject(new Error('needle must be a non-empty string')) if (this.nextWaitResult !== undefined) return Promise.resolve({ kind: 'skipped', needle }) return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, match: needle, elapsedMs: 1 }) } ``` (b) 在 `describe('agent terminal tools')` 内、现有 `terminal_wait_for returns the registry result...` 用例后追加: ```ts it('terminal_wait_for returns skipped (schema-valid) when the registry reports a user skip', async () => { const { captured, registry } = mount() const tool = toolOf(captured, 'terminal_wait_for') registry.nextWaitResult = { kind: 'skipped' } const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1')) expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' }) expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) }) it('terminal_wait_for render describes the user skip and the needle', () => { const { captured } = mount() const tool = toolOf(captured, 'terminal_wait_for') const blocks = tool.output.render({}, { kind: 'skipped', needle: 'BUILD_OK' }) as Array<{ type: string; text: string }> expect(blocks[0]!.type).toBe('text') expect(blocks[0]!.text).toContain('Skipped by user') expect(blocks[0]!.text).toContain('BUILD_OK') }) ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm exec vitest run tests/tools.spec.ts` Expected: FAIL(schema 拒绝 skipped / render 走到 exited 分支输出不符)。 - [ ] **Step 3: 实现** (a) `src/tools.ts` terminal_wait_for: 描述末尾(`'The wait is cooperative: ...'` 句之后)追加一句: ```ts + 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.', ``` output.schema 的 `oneOf` 数组末尾(exited 分支对象之后)追加: ```ts { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'skipped' }, needle: { type: 'string', required: true }, }, }, ``` render 的 value 类型与分支 —— `const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; ... }`(原行加 `'skipped'`),并在 timeout 分支后插入: ```ts if (v.kind === 'skipped') { return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }] } ``` (b) `src/index.ts` buildApi 表,`'agent-pty.close'` 条目(约 541-545 行)之后追加: ```ts // The sidebar wait banner's skip button: abort every active // terminal_wait_for on one agent terminal. Idempotent — 0 when nothing // is waiting (a stale banner racing a wait that already resolved). // Degraded mode (node-pty unavailable) has no registry and no waits: an // honest ok. 'agent-pty.skip-wait': (payload) => { const uuid = requireString(payload, 'uuid') return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 } }, ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm exec vitest run tests/tools.spec.ts` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add src/tools.ts src/index.ts tests/tools.spec.ts git commit -m "feat(terminal): terminal_wait_for 增 skipped 结果与 agent-pty.skip-wait 路由" ``` --- ### Task 3: 客户端状态 `agentWaits` 镜像 + 推送类型 **Files:** - Modify: `src/client/state.ts`(SidebarState 接口 ~121 行、makeDefaultState ~221 行、sanitizeState ~1283 行、reconcileAgentTerminals ~1017 行) - Modify: `src/client/sidebar/use-host-feeds.ts`(~66 行解析类型) - Test: `tests/agent-terminal-reconcile.spec.ts` **Interfaces:** - Consumes: 宿主推送数组(Task 1 的 snapshot 含 `waiting`)。 - Produces: - `SidebarState.agentWaits: Record`(uuid → 活动等待;瞬态,不持久化) - `reconcileAgentTerminals(state, list)` 的 list 元素增 `waiting?: { needle: string; since: number } | null`,并把等待状态镜像进 `agentWaits`(仅等待变化也产出新状态)。 - [ ] **Step 1: 写失败测试** `tests/agent-terminal-reconcile.spec.ts` 追加两个用例(沿用文件现有 import,无需新增): ```ts it('mirrors the pushed waiting state into agentWaits and clears it when it ends', () => { let s = makeDefaultState() s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'busy', waiting: { needle: 'READY_1', since: 42 } }]) expect(s.agentWaits['aaa-111']).toEqual({ needle: 'READY_1', since: 42 }) // The next push without waiting drops the entry (the push is authoritative). s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) expect(s.agentWaits['aaa-111']).toBeUndefined() }) it('produces a new state when ONLY the wait state changes (no tab add/remove)', () => { let s = makeDefaultState() s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) const before = s const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }]) expect(next).not.toBe(before) expect(next.agentWaits['aaa-111']).toBeDefined() // Idempotent: the same push again returns the same reference. expect(reconcileAgentTerminals(next, [{ uuid: 'aaa-111', title: 'stable', waiting: { needle: 'N', since: 1 } }])).toBe(next) }) ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts` Expected: FAIL(`agentWaits` 不存在 → TS 错误 / 断言失败)。 - [ ] **Step 3: 实现** (a) `SidebarState` 接口 `floats: FloatWindow[]` 之后追加: ```ts /** * Live agent-terminal wait state (uuid → the wait the model currently * blocks on in `terminal_wait_for`), mirrored from the host's * agent-terminals push. Transient by design: sanitizeState never restores * it, so a reload starts clean and the next push (sent immediately on WS * attach) repopulates it. */ agentWaits: Record ``` (b) `makeDefaultState` 的返回字面量 `floats: [],` 之后加 `agentWaits: {},`。 (c) `sanitizeState` 的返回字面量 `floats,` 之后加(带注释): ```ts // The agent wait state is TRANSIENT (like revealed): never restored from // storage — the host's first push after attach repopulates it. agentWaits: {}, ``` (d) `reconcileAgentTerminals`:参数类型改为 ```ts agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>, ``` 在 `const toRemove = ...` 之后、`if (toAdd.length === 0 && toRemove.length === 0) return state` 处,替换为: ```ts // Mirror the live wait state from the push (authoritative: a vanished // waiting field simply drops the entry). A waits-only change must still // produce a new state — the tab add/remove no-change check alone would // swallow banner updates. const serverWaits: Record = {} for (const terminal of agentTerminals) { if (terminal.waiting !== undefined && terminal.waiting !== null) { serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since } } } if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state ``` 文件末尾的 `return next` 改为 `return { ...next, agentWaits: serverWaits }`。 在 `reconcileAgentTerminals` 前方新增辅助函数: ```ts /** Shallow equality of two agent-wait maps (same keys, same needle+since). */ function sameAgentWaits( a: SidebarState['agentWaits'] | undefined, b: Record, ): boolean { if (a === undefined) return Object.keys(b).length === 0 const aKeys = Object.keys(a) if (aKeys.length !== Object.keys(b).length) return false for (const key of aKeys) { const av = a[key] const bv = b[key] if (av === undefined || bv === undefined) return false if (av.needle !== bv.needle || av.since !== bv.since) return false } return true } ``` (e) `src/client/sidebar/use-host-feeds.ts` 66 行的解析类型改为: ```ts const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }> ``` - [ ] **Step 4: 跑测试确认通过 + 全仓 typecheck(状态字段新增会暴露遗漏的构造点)** Run: `pnpm exec vitest run tests/agent-terminal-reconcile.spec.ts && pnpm typecheck` Expected: PASS。若 typecheck 报其他 `SidebarState` 字面量缺 `agentWaits`(tests 里的手搓状态),补 `agentWaits: {}` —— **只许改测试里测试自己构造的字面量,不许改上游断言逻辑**。 - [ ] **Step 5: Commit** ```bash git add src/client/state.ts src/client/sidebar/use-host-feeds.ts tests/agent-terminal-reconcile.spec.ts git commit -m "feat(sidebar): 镜像 agent 终端等待状态到 agentWaits(瞬态)" ``` --- ### Task 4: 等待 banner 组件 + TerminalView 接线 + api 方法 + i18n + CSS **Files:** - Create: `src/client/TerminalWaitBanner.tsx` - Modify: `src/client/TerminalView.tsx`(render 区 ~387-408 行 + 组件顶部订阅) - Modify: `src/client/api.ts`(`agentPtyClose` 之后 ~348 行) - Modify: `src/client/locales.ts`(zh ~117 / en ~567,紧随 `terminalRetry`) - Modify: `src/client/locales-ja.ts`(~105,紧随 `terminalRetry`) - Modify: `src/client/sidebar.module.css`(`.terminalBanner` 块之后 ~2202 行) - Test: `tests/terminal-wait-banner.spec.tsx`(新建) **Interfaces:** - Consumes: Task 2 的 `agent-pty.skip-wait` 路由;Task 3 的 `state.agentWaits`;`api` 对象、`t()`、`css` module。 - Produces: `TerminalWaitBanner({ needle: string; onSkip: () => void })` + `truncateNeedle(needle: string): string`(80 字符截断);词典键 `terminalWaitBanner` / `terminalSkipWait`。 - [ ] **Step 1: 写失败测试(新文件 `tests/terminal-wait-banner.spec.tsx`)** ```tsx /** * TerminalWaitBanner unit tests: localized text with the needle, the skip * button wiring, and the long-needle display cap (full text stays on the * title attribute). The component imports no xterm — renderable in plain * jsdom (the repo's jsdom pattern, see bottom-auto-terminal.spec.tsx head). */ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { setupReactAct } from './test-utils.ts' setupReactAct() import { TerminalWaitBanner } from '../src/client/TerminalWaitBanner.tsx' describe('TerminalWaitBanner', () => { const mounted: Array<{ root: Root; container: HTMLDivElement }> = [] function mount(needle: string, onSkip: () => void): HTMLDivElement { const container = document.createElement('div') document.body.append(container) const root = createRoot(container) act(() => { root.render(createElement(TerminalWaitBanner, { needle, onSkip })) }) mounted.push({ root, container }) return container } afterEach(() => { for (const { root, container } of mounted.splice(0)) { act(() => { root.unmount() }) container.remove() } }) it('shows the localized waiting text with the needle and a working skip button', () => { let skipped = 0 const container = mount('BUILD_OK', () => { skipped += 1 }) // jsdom navigator.language is en-US → the en dictionary applies. expect(container.textContent).toContain('Agent is waiting for') expect(container.textContent).toContain('BUILD_OK') expect(container.textContent).toContain('Skip wait') const button = container.querySelector('button') expect(button).not.toBeNull() act(() => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(skipped).toBe(1) }) it('truncates a long needle for display but keeps the full text on the title', () => { const long = 'X'.repeat(200) const container = mount(long, () => {}) const span = container.querySelector('span[title]') expect(span).not.toBeNull() expect(span!.getAttribute('title')).toBe(long) expect((span!.textContent ?? '').length).toBeLessThanOrEqual(80) }) }) ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx` Expected: FAIL(模块不存在)。 - [ ] **Step 3: 实现** (a) 新建 `src/client/TerminalWaitBanner.tsx`: ```tsx /** * The "Agent 正在等待 {needle}" wait banner: rendered at the top of an * agent-owned terminal's view while the model blocks in terminal_wait_for. * The skip button asks the host to abort every active wait on the terminal * (`agent-pty.skip-wait`); the banner disappears when the host's next * agent-terminals push drops the waiting field — no optimistic UI. Kept in * its own module (no xterm imports) so jsdom tests can render it directly. */ import { t } from './locales.ts' import css from './sidebar.module.css' /** Cap the needle shown inline; the full text rides the title tooltip. */ const NEEDLE_DISPLAY_CAP = 80 /** Truncate one needle for inline display (title attr carries the full text). */ export function truncateNeedle(needle: string): string { return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle } export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) { const { needle, onSkip } = props return (
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
) } ``` (b) `src/client/api.ts`:`agentPtyClose` 条目之后追加: ```ts /** Skip every active terminal_wait_for on one agent terminal (the wait * banner's skip button). Idempotent: {skipped:0} when none is active. */ agentSkipWait: (uuid: string) => call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }), ``` (c) `src/client/locales.ts`:zh 词典 `terminalRetry: '重试',` 行后加: ```ts terminalWaitBanner: 'Agent 正在等待 {needle}', terminalSkipWait: '跳过等待', ``` en 词典 `terminalRetry: 'Retry',` 行后加: ```ts terminalWaitBanner: 'Agent is waiting for {needle}', terminalSkipWait: 'Skip wait', ``` (d) `src/client/locales-ja.ts`:`terminalRetry: '再試行',` 行后加(仓库规则:新 zh 键必须同步 ja): ```ts terminalWaitBanner: 'エージェントが {needle} を待機中', terminalSkipWait: '待機をスキップ', ``` (e) `src/client/sidebar.module.css`:`.terminalBannerUrl` 块之后加: ```css /* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the terminal surface while the model blocks in terminal_wait_for; the needle ellipsizes inline and the full text rides the title tooltip. */ .terminalWaitBanner { flex: none; display: flex; align-items: center; gap: 8px; padding: 3px 10px; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-tertiary); } .terminalWaitNeedle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } ``` (f) `src/client/TerminalView.tsx`: imports 区追加: ```ts import { TerminalWaitBanner } from './TerminalWaitBanner.tsx' ``` 组件体内(`const [lastUrl, setLastUrl] = ...` 之后)加等待订阅: ```ts // Agent terminals only: the model's active terminal_wait_for (mirrored // from the host's agent-terminals push into the store) drives the wait // banner. Read + subscribe like the font prefs above; the banner vanishes // when the host's push drops the waiting field (skip / exit / abort all // converge through the same push). getSnapshot() is {sessionId, state?, // prefs} — the state may be briefly undefined around session switches. const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined) useEffect(() => { if (agentUuid === null) return const read = (): void => { const next = store.getSnapshot().state?.agentWaits?.[agentUuid] setWaiting(prev => { const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since } if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev return nextValue }) } read() return store.subscribe(read) }, [agentUuid, store]) ``` return JSX 中 `
` 的第一个子元素位置(`depsFatal` 条目之前)加: ```tsx {agentUuid !== null && waiting !== undefined && ( { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }} /> )} ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add src/client/TerminalWaitBanner.tsx src/client/TerminalView.tsx src/client/api.ts src/client/locales.ts src/client/locales-ja.ts src/client/sidebar.module.css tests/terminal-wait-banner.spec.tsx git commit -m "feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)" ``` --- ### Task 5: tab 标题 ⏳ 徽章(shell 层特例)+ jsdom 链路测试 **Files:** - Modify: `src/client/Sidebar.tsx`(`tabBadgeOf` ~828-841 行) - Test: `tests/agent-wait-badge.spec.tsx`(新文件,harness 照抄 `tests/bottom-auto-terminal.spec.tsx` 1-97 行的模式) **Interfaces:** - Consumes: Task 3 的 `state.agentWaits`;`isAgentTabId` / `agentUuidOf`(Sidebar.tsx 已 import,第 38-39 行)。 - Produces: agent tab 的 ⏳ 徽章 pill(复用 `css.tabBadge`);推送 → 状态 → 徽章全链路。 - [ ] **Step 1: 写失败测试(新文件 `tests/agent-wait-badge.spec.tsx`)** 完整文件(FakeWebSocket 记录 url 与实例,驱动 `/sidebar/ws/agent-terminals` 推送;`registerStubTerminal` 防止懒加载 chunk 在 jsdom 里拉真实 bundle): ```tsx /** * The agent-terminal wait badge chain, end to end in jsdom: the host's * agent-terminals push (mirroring the real /sidebar/ws/agent-terminals * frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders * the hourglass pill on the agent tab while a wait is live and drops it * when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx * (real Sidebar shell + fake context + stubbed WebSocket). */ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { setupReactAct } from './test-utils.ts' setupReactAct() import { Sidebar } from '../src/client/Sidebar.tsx' import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts' import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' /** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */ class FakeWebSocket { static instances: FakeWebSocket[] = [] onmessage: ((event: { data: unknown }) => void) | null = null onclose: (() => void) | null = null onerror: (() => void) | null = null close = (): void => {} constructor(readonly url: string) { FakeWebSocket.instances.push(this) } } let sessionSeq = 0 function mountSidebar(): { container: HTMLDivElement; store: ReturnType; service: BetterSidebarService; unmount: () => void } { vi.stubGlobal('WebSocket', FakeWebSocket) FakeWebSocket.instances = [] const container = document.createElement('div') document.body.append(container) const store = createSidebarStore() const service = createBetterSidebarService(store) // Replace the terminal descriptor with a stub: the real one lazy-loads the // xterm chunk, which jsdom cannot fetch — the badge under test renders in // the TAB STRIP, independent of the view component. service.registerTab({ id: 'terminal', title: () => 'Terminal', component: () => null, }) const sessionId = `s1-${++sessionSeq}` store.setSession(sessionId) // Stub the terminal descriptor: the real one lazy-loads the xterm chunk, // which jsdom cannot fetch (the bottom-auto-terminal harness does the same). service.registerTab({ id: 'terminal', title: () => 'Terminal', component: () => null, }) const localeSnapshot = { active: 'en' } const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, betterSidebar: service, get: (name: string) => name === 'betterSidebar' ? service : undefined, } const root: Root = createRoot(container) act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) }) return { container, store, service, unmount: () => { act(() => { root.unmount() }) container.remove() }, } } let sessionSeq = 0 afterEach(() => { document.body.innerHTML = '' localStorage.clear() vi.unstubAllGlobals() }) /** The agent-terminals push socket the shell opened for the current session. */ function feedsSocket(): FakeWebSocket { const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals')) if (socket === undefined) throw new Error('agent-terminals socket not connected') return socket } describe('agent terminal wait badge (push → state → tab pill)', () => { it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => { const { container } = mountSidebar() const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false } // First push: the agent tab lands in the active pane. act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) }) expect(container.textContent).toContain('dev server') expect(container.textContent).not.toContain('⏳') // Second push carries a live wait → the hourglass pill appears. act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) }) expect(container.textContent).toContain('⏳') // The wait resolved (skip/exit/abort all converge to a waiting-less push). act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) }) expect(container.textContent).not.toContain('⏳') }) it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => { const { container, store } = mountSidebar() // A UI-owned terminal tab (id NOT agent:) lives in the strip too; the // badge lookup keys on the agent uuid, so it never badges. act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) }) const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false } act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) }) // Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS // module class names are not stable under the test transform.) expect((container.textContent.match(/⏳/g) ?? []).length).toBe(1) // The wait resolved → the pill disappears entirely. act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) }) expect(container.textContent).not.toContain('⏳') }) }) ``` > 实现注意:`sessionSeq` 声明放 `mountSidebar` 之前(与 bottom-auto-terminal.spec.tsx 相同的模块级写法);`createSidebarStore` 返回类型直接用 `ReturnType`,勿手写接口。 - [ ] **Step 2: 跑测试确认失败** Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx` Expected: FAIL(⏳ 从不出现)。 - [ ] **Step 3: 实现 Sidebar.tsx 的 `tabBadgeOf` 特例** `src/client/Sidebar.tsx` 828 行 `const tabBadgeOf = (tab: SidebarTab): ReactNode => {` 函数体开头(descriptor 查找之前)插入: ```ts // Agent-terminal wait indicator (sidebar-internal, deliberately NOT a // TabDescriptor.badge — that API is type-keyed and shared with external // plugins, and cannot address one tab): the agent-terminals push mirrors // the model's live terminal_wait_for into state.agentWaits; an agent tab // whose uuid is waiting shows the hourglass pill. if (isAgentTabId(tab.id)) { const wait = state.agentWaits?.[agentUuidOf(tab.id)] if (wait !== undefined) return {'⏳'} } ``` (`isAgentTabId` / `agentUuidOf` 已在 38-39 行 import;`css` / `state` 均在作用域内。) - [ ] **Step 4: 跑测试确认通过** Run: `pnpm exec vitest run tests/agent-wait-badge.spec.tsx` Expected: PASS(2 个用例)。 - [ ] **Step 5: Commit** ```bash git add src/client/Sidebar.tsx tests/agent-wait-badge.spec.tsx git commit -m "feat(sidebar): agent 终端 tab 标题在等待时显示 ⏳ 徽章" ``` --- ### Task 6: 设计文档偏差记录 + 全量验证 + 推送 + PR **Files:** - Modify: `docs/plans/2026-09-09-terminal-wait-banner-design.md`(实施偏差记录节) - 无代码改动。 - [ ] **Step 1: 更新设计文档** 在 `docs/plans/2026-09-09-terminal-wait-banner-design.md` 末尾追加: ```markdown ## 实施偏差记录 - **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed (`badge(ctx, scope, state)`,无 tab 参数,external 插件共享),无法定位单个 tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: tab 前缀 判断 + `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md 零改动。 - **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在 TerminalView.tsx 内;抽出后无 xterm 依赖,可直接被 jsdom 单测渲染 (TerminalView 仍负责订阅 store 并接线 onSkip → api.agentSkipWait)。 - **设计文档随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝 (`4a6a77a` 随本分支合并)。 - **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的 warn 对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。 ``` - [ ] **Step 2: 全量验证** Run(依次,全绿才继续): `pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build` Expected: - lint: 0 error(历史上 CI 因 no-useless-assignment 挂过——新代码避免「先声明后必然覆盖」的写法); - test: 除已知的 win32 非回归失败集(bottom-auto-terminal / free-window / sidebar-auto-activation / sidebar-crash 等既有失败,对照 main 基线)外全绿,新增 spec 全部 PASS; - build: lib/ 产物含新 chunk `client.js` 变化(TerminalWaitBanner 打进 client bundle)。 - [ ] **Step 3: 提交并推送** ```bash git add docs/plans/2026-09-09-terminal-wait-banner-design.md git commit -m "docs(plans): 终端等待 banner 实施偏差记录" git push -u origin feat/wait-banner ``` - [ ] **Step 4: 建 PR** ```bash gh pr create --repo omdsh-dev/DSH-better-sidebar --base main --head feat/wait-banner \ --title "feat(terminal): 等待 banner + 跳过等待(terminal_wait_for skipped)" \ --body "设计:docs/plans/2026-09-09-terminal-wait-banner-design.md(随本 PR)。agent 阻塞在 terminal_wait_for 时:对应终端卡片顶部显示「Agent 正在等待 {needle}」banner,右侧跳过按钮走 agent-pty.skip-wait;tab 标题 ⏳ 徽章;工具新增 {kind:'skipped'} 返回。" ``` - [ ] **Step 5: 等待 CI(用 terminal_wait_for 盯 `(ci|ci-windows|plugin-mount).*\((pass|fail)\)` 之类的多结局 needle,勿 sleep)** Expected: 三个 job 全 pass。