From 4a6a77a27eaf4064e869723b272eab0d47f63465 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:03:34 +0800 Subject: [PATCH 01/13] =?UTF-8?q?docs(plans):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E7=AD=89=E5=BE=85=20banner=20+=20=E8=B7=B3=E8=BF=87=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-09-terminal-wait-banner-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/plans/2026-09-09-terminal-wait-banner-design.md diff --git a/docs/plans/2026-09-09-terminal-wait-banner-design.md b/docs/plans/2026-09-09-terminal-wait-banner-design.md new file mode 100644 index 0000000..dd4bfd7 --- /dev/null +++ b/docs/plans/2026-09-09-terminal-wait-banner-design.md @@ -0,0 +1,120 @@ +# 终端等待 banner(wait banner + 跳过)设计 + +> 2026-09-09 · 目标 v0.18.2 + +## 背景 + +`terminal_wait_for`(v0.18.1,PR #595 起 needle 支持 regex)会阻塞 agent 的工具调用直到 +needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到任何"agent 正在等什么"的信号, +也无法干预——只能等 agent 自己超时(默认 10s,长等待可设到分钟级)。 + +需求: + +1. agent 开始 `wait for needle` 时,在对应终端的顶部显示 banner: + **「Agent 正在等待 {needle}」**,右侧「跳过等待」按钮; +2. 点击跳过后,`terminal_wait_for` 立即返回,agent 明确知道是用户跳过(而非超时); +3. 终端 tab 未激活时也能感知等待 → tab 标题加 ⏳ 徽章。 + +## 方案对比 + +| 方案 | 思路 | 取舍 | +|---|---|---| +| **A. 搭现有推送车(采纳)** | 等待状态并入 `AgentTerminalSnapshot`,经现有 `/sidebar/ws/agent-terminals` 推送;跳过走新 `/sidebar/api` 方法 | 改动集中;零新 WS 生命周期;推送时机由 registry `notify()` 天然覆盖 | +| B. 独立 waits 推送 WS | 新开 `/sidebar/ws/agent-waits` | 重连/订阅/teardown 全套翻倍,YAGNI | +| C. 客户端轮询 | 定时拉 terminal 列表 | 延迟与开销差,违背现有推送架构 | + +已确认的两个决策: + +- **skip 语义 = 新增第四种返回 `kind:'skipped'`**(不复用 timeout、不走 abort——abort + 语义属于工具调用取消,用户语义只是"不等了"); +- **banner 主体在终端卡片顶部 + tab 标题 ⏳ 徽章**(复用 v0.12+ 的 `TabDescriptor.badge` + 通道,git tab 有先例)。 + +## 设计 + +### 1. Host:`AgentPtyRegistry`(src/agent-pty.ts) + +- `AgentTerminalHandle` 增活动等待记录数组 `waits: Array<{ needle, since, skipped }>`; + 并发 `wait_for` 同 uuid 时各登记一条。 +- `waitFor()`:快速路径(已 exited / needle 已在 transcript)不登记、不推 banner; + 进入轮询前登记记录并 `notify()`(banner 出现),`finally` 移除记录并 `notify()`(banner + 消失——found/timeout/exited/skipped/工具 abort 五条出路统一走 finally)。 +- 轮询每圈优先检查本记录的 `skipped` → 返回 `{ kind:'skipped', needle }`;50ms 轮询 + 天然承载跳过延迟,无需事件唤醒。 +- 新方法 `skipWait(uuid): number`:标记该 uuid 全部活动记录并返回条数;uuid 未知 → + `not-found` 404(与 `expect` 一致)。 +- `AgentTerminalSnapshot` 增 `waiting?: { needle: string; since: number }`(取最新登记 + 的一条;并发等待显示最新 needle,跳过跳过全部)。 +- `AgentTerminalWaitResult` 增 `skipped` 变体。 + +### 2. 工具层:`terminal_wait_for`(src/tools.ts) + +- output schema `oneOf` 增 `{ kind: 'skipped', needle }` 分支; +- render:`Skipped by user while waiting for "" — the wait ended early; call terminal_read to inspect the transcript if needed.`; +- 工具描述补一句:用户可从侧栏 banner 跳过等待(返回 `skipped`)。 + +### 3. API:`buildApi`(src/index.ts) + +- 新方法 `'agent-pty.skip-wait'`(命名对齐现有 `'agent-pty.close'`): + `requireString uuid` → `registry.skipWait(uuid)`;degraded 模式(registry 为 null) + 幂等返回 `{ ok: true, skipped: 0 }`。 + +### 4. 推送零改动 + +`attachAgentList` 发的就是 `registry.list(sessionId)`,snapshot 新增的 `waiting` 字段 +自动随车;等待开始/结束由 `notify()` 触发推送。兼容性: + +- 旧客户端 + 新 host:多余字段被忽略; +- 新客户端 + 旧 host:永远收不到 `waiting` → 无 banner,安全降级。 + +### 5. 客户端状态(src/client/state.ts) + +- `SidebarState` 增 `agentWaits: Record`(默认 + `{}`;等待结束即删除键); +- `reconcileAgentTerminals` 从同一推送同步该映射——需解除"无 tab 增删即早退"的短路 + (仅 waiting 变化也要产出新状态); +- **不持久化**:`sanitizeState` 逐字段重建、不恢复该字段 → 刷新后自然清空;WS attach + 即发首条推送,立刻纠正。 + +### 6. Banner(src/client/TerminalView.tsx) + +- 仅 `agent:` tab 渲染;位置在终端区顶部(现有 fatal/deps banner 之上); +- 内容:`t('terminalWaitBanner', { needle })`(zh「Agent 正在等待 {needle}」)+ 右侧 + `t('terminalSkipWait')`(zh「跳过等待」)按钮 → `api.agentSkipWait(uuid)`; +- needle 超 ~80 字符截断显示,`title` 悬浮显全文; +- banner 消失完全由推送驱动(skipped/exited/abort 均经 `notify`),点击不做乐观更新。 + +### 7. Tab ⏳ 徽章(src/client/builtins/tabs.tsx) + +- terminal 描述符增 `badge: (_ctx, scope, state) => ...`:`agent:` tab 且 + `state.agentWaits[uuid]` 存在时返回 `'⏳'`,否则 null——复用 v0.12+ 徽章通道 + (git changes tab 先例),不碰 `TabBar`。 + +### 8. i18n / 样式 + +- 词典键:`terminalWaitBanner`、`terminalSkipWait`;zh/en 同步,**ja 翻译必须同步 + `src/client/locales-ja.ts`**(仓库规则); +- `.terminalWaitBanner` 全部消费 `--dsw-alias-*` / `--ds-*` 令牌,无硬编码颜色 + (皮肤契约,`tests/theme.spec.ts` 守护)。 + +### 9. 测试 + +- `tests/agent-pty.spec.ts`:跳过返回 `skipped`;活动等待期间 snapshot 含 `waiting`、 + 结束后消失;`skipWait` 未知 uuid → 404、无活动等待 → 0;等待开始/结束触发 `notify`; +- `tests/tools.spec.ts`:FakeRegistry 增 skipped 形状;schema/render 断言; +- `tests/agent-terminal-reconcile.spec.ts`:`agentWaits` 设置/清除、仅 waiting 变化也 + 触发状态更新; +- badge / banner 组件测试视现有 jsdom 基建可行性补齐。 + +## 边缘情况 + +- **并发多个 `wait_for` 同一 uuid**:banner/⏳ 显示最新登记的 needle;跳过一次跳过全部; +- **工具调用取消(abort)**:`finally` 移除记录 → banner 消失并推送; +- **终端退出 / 用户关 tab(close frame 杀 pty)**:轮询下一圈返回 `exited`,记录移除; +- **UI-tab 终端**:无等待概念,`agentWaits` 永不含有其条目。 + +## 不做的事 + +- 不新增独立 waits WebSocket(方案 B 否决); +- 不改 `ctx.betterSidebar` 服务面(`external-plugin-guide.md` 无需同步); +- 不做乐观 UI(banner 消失以服务端推送为准)。 From d660f33966ad99a4210261920a9abd783893248f Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:12:19 +0800 Subject: [PATCH 02/13] =?UTF-8?q?docs(plans):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E7=AD=89=E5=BE=85=20banner=20=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-09-terminal-wait-banner-plan.md | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/plans/2026-09-09-terminal-wait-banner-plan.md diff --git a/docs/plans/2026-09-09-terminal-wait-banner-plan.md b/docs/plans/2026-09-09-terminal-wait-banner-plan.md new file mode 100644 index 0000000..57160d4 --- /dev/null +++ b/docs/plans/2026-09-09-terminal-wait-banner-plan.md @@ -0,0 +1,967 @@ +# 终端等待 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, type SidebarStore } 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) + 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('never shows ⏳ on UI-owned terminal tabs', () => { + const { container } = mountSidebar() + // A UI terminal tab (id NOT agent:) with a same-named wait never badges — + // the lookup keys on the agent uuid only. + act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ uuid: 'u2', title: 'ui', command: '', exited: false, waiting: { needle: 'N', since: 1 } }]) }) }) + act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([]) }) }) + 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。 From 24480683697349fc014767b61e4e03e67b511577 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:15:49 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docs(plans):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20Task=205=20=E6=B5=8B=E8=AF=95=EF=BC=88stub?= =?UTF-8?q?=20=E6=8F=8F=E8=BF=B0=E7=AC=A6=E3=80=81UI=20tab=20=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E3=80=81=E6=96=87=E6=9C=AC=E8=AE=A1=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-09-terminal-wait-banner-plan.md | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-09-09-terminal-wait-banner-plan.md b/docs/plans/2026-09-09-terminal-wait-banner-plan.md index 57160d4..2fc03d9 100644 --- a/docs/plans/2026-09-09-terminal-wait-banner-plan.md +++ b/docs/plans/2026-09-09-terminal-wait-banner-plan.md @@ -769,7 +769,7 @@ import { setupReactAct } from './test-utils.ts' setupReactAct() import { Sidebar } from '../src/client/Sidebar.tsx' -import { createSidebarStore, type SidebarStore } from '../src/client/state.ts' +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. */ @@ -803,6 +803,13 @@ function mountSidebar(): { container: HTMLDivElement; store: ReturnType 'Terminal', + component: () => null, + }) const localeSnapshot = { active: 'en' } const sessionsSnapshot = { current: sessionId, @@ -858,12 +865,18 @@ describe('agent terminal wait badge (push → state → tab pill)', () => { expect(container.textContent).not.toContain('⏳') }) - it('never shows ⏳ on UI-owned terminal tabs', () => { - const { container } = mountSidebar() - // A UI terminal tab (id NOT agent:) with a same-named wait never badges — - // the lookup keys on the agent uuid only. - act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ uuid: 'u2', title: 'ui', command: '', exited: false, waiting: { needle: 'N', since: 1 } }]) }) }) - act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([]) }) }) + 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('⏳') }) }) From fb2afabc7210d54ab85d97e81a360a72651cccc0 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:20:08 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(terminal):=20registry=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=AD=89=E5=BE=85=E7=99=BB=E8=AE=B0=E3=80=81skipWait?= =?UTF-8?q?=20=E4=B8=8E=20skipped=20=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent-pty.ts | 102 ++++++++++++++++++++++++++++++++-------- tests/agent-pty.spec.ts | 65 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 20 deletions(-) diff --git a/src/agent-pty.ts b/src/agent-pty.ts index ffdaee6..8a1472c 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -134,6 +134,13 @@ export interface AgentTerminalSnapshot { exitCode?: number | null /** Exit signal name if the process was killed by a signal; null otherwise. */ exitSignal?: string | null + + /** + * 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 } } /** Map a POSIX signal number to its conventional name (best-effort). */ @@ -223,6 +230,8 @@ export interface AgentTerminalHandle { exitCode?: number | null /** Exit signal number once known (POSIX only; undefined on Windows). */ exitSignal?: number | null + /** Active wait_for registrations (skip bookkeeping; empty while idle). */ + waits: AgentTerminalActiveWait[] } /** Read result shape (mirrors the official tool-pty terminal_read contract). */ @@ -237,6 +246,16 @@ export interface AgentTerminalReadResult { lineEnd: number } +/** 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 +} + /** Outcome of {@link AgentPtyRegistry.waitFor}. */ export type AgentTerminalWaitResult = | { @@ -276,6 +295,12 @@ export type AgentTerminalWaitResult = /** The exit signal name, if the process was killed by a signal. */ exitSignal?: string | null } + | { + /** The user skipped the wait from the sidebar banner. */ + kind: 'skipped' + /** The needle that was awaited. */ + needle: string + } /** Snapshot projection of a handle (drops the pty reference and transcript). */ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { @@ -289,6 +314,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { out.exitCode = handle.exitCode ?? null out.exitSignal = signalNameOf(handle.exitSignal) } + const active = handle.waits.at(-1) + if (active !== undefined) out.waiting = { needle: active.needle, since: active.since } return out } @@ -349,6 +376,7 @@ export class AgentPtyRegistry { pty, transcript: '', exited: false, + waits: [], } pty.onData((data) => { handle.transcript += data @@ -503,7 +531,8 @@ export class AgentPtyRegistry { * can tell which outcome hit. * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms. * @param signal - caller-owned cancellation; aborts the wait re-throwing. - * @returns one of `found` / `timeout` / `exited`. + * A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`. + * @returns one of `found` / `timeout` / `exited` / `skipped`. */ async waitFor( uuid: string, @@ -531,29 +560,62 @@ export class AgentPtyRegistry { if (firstHit !== undefined) { return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start } } - // Poll loop: check the transcript every 50ms, exit on match / exit / - // abort / timeout. The handle is read live each iteration (its transcript - // and exited fields mutate as the pty produces output). - while (true) { - if (signal?.aborted) signal.throwIfAborted() - if (handle.exited) { - return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } + // 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() + }) } - 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() } } + /** + * 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 + } + /** * Send a POSIX signal to a terminal's foreground process. * diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index b5b67ec..6e4bdf4 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -393,6 +393,71 @@ describe('AgentPtyRegistry', () => { await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow() }) + 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() + } + }) + it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => { const registry = new AgentPtyRegistry(testShell()) try { From 993f7aaacd391c6750c98da1421fdee4965535ad Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:26:36 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(terminal):=20terminal=5Fwait=5Ffor?= =?UTF-8?q?=20=E5=A2=9E=20skipped=20=E7=BB=93=E6=9E=9C=E4=B8=8E=20agent-pt?= =?UTF-8?q?y.skip-wait=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 9 +++++++++ src/tools.ts | 16 ++++++++++++++-- tests/tools.spec.ts | 28 +++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4638174..37f2c15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -543,6 +543,15 @@ function buildApi( agentPtyRegistry?.close(uuid) return { ok: true } }, + // 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 } + }, // Terminal dependency status (issue #140): after a WS close 1011 with // reason `pty-deps-missing` the client fetches the full repair details // here — the close reason itself is capped at 123 bytes, too small for diff --git a/src/tools.ts b/src/tools.ts index 39632ab..7c96b45 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -296,7 +296,8 @@ export function registerTools( + 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. ' + 'Returns `found` with the line/column and the matched text, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. ' + 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). ' - + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.', + + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately. ' + + '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`.', parameters: { uuid: { type: 'string', @@ -349,10 +350,18 @@ export function registerTools( exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' }, }, }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'skipped' }, + needle: { type: 'string', required: true }, + }, + }, ], }, render: (_args, value) => { - const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; match?: string; exitCode?: number | null; exitSignal?: string | null } + const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; match?: string; exitCode?: number | null; exitSignal?: string | null } if (v.kind === 'found') { const matched = v.match !== undefined && v.match !== '' ? `, matched "${v.match}"` : '' return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column}${matched} (after ${v.elapsedMs}ms).` }] @@ -360,6 +369,9 @@ export function registerTools( if (v.kind === 'timeout') { return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }] } + 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.` }] + } const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : '' return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }] }, diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts index 80791a7..197f5b4 100644 --- a/tests/tools.spec.ts +++ b/tests/tools.spec.ts @@ -22,6 +22,9 @@ interface CapturedTool { class FakeRegistry { readonly terminals = new Map() + /** When set, waitFor resolves with the skipped shape instead of the found default. */ + nextWaitResult: { kind: 'skipped' } | undefined = undefined + create(sessionId: string, title: string, command: string): string { const uuid = `uuid-${this.terminals.size + 1}` this.terminals.set(uuid, { sessionId, title, command, exited: false }) @@ -58,10 +61,14 @@ class FakeRegistry { return true } - waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; match: string; elapsedMs: number }> { + 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 }) } } @@ -193,6 +200,25 @@ describe('agent terminal tools', () => { expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) }) + 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.create('s1', 'skipper', '') + 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') + }) + it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => { const { captured, registry } = mount() const tool = toolOf(captured, 'terminal_wait_for') From 1fa777293fe58cb0b68f7870017320c3d3cffe50 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:29:27 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat(sidebar):=20=E9=95=9C=E5=83=8F=20age?= =?UTF-8?q?nt=20=E7=BB=88=E7=AB=AF=E7=AD=89=E5=BE=85=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=88=B0=20agentWaits=EF=BC=88=E7=9E=AC=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/sidebar/use-host-feeds.ts | 2 +- src/client/state.ts | 45 ++++++++++++++++++++++++-- tests/agent-terminal-reconcile.spec.ts | 20 ++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts index 0028690..4207e38 100644 --- a/src/client/sidebar/use-host-feeds.ts +++ b/src/client/sidebar/use-host-feeds.ts @@ -63,7 +63,7 @@ export function useHostFeeds(feeds: { socket.onmessage = (event) => { if (typeof event.data !== 'string') return try { - const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }> + const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }> if (!Array.isArray(list)) return store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false ? s diff --git a/src/client/state.ts b/src/client/state.ts index 172415c..1a3c33b 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -118,6 +118,14 @@ export interface SidebarState { bottomSplits: SplitNode /** Free windows (tabs dragged out onto the conversation area). */ floats: FloatWindow[] + /** + * 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 } export const PANEL_MIN = 280 @@ -232,6 +240,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: bottomOpenedOnce: false, bottomSplits: bottomLeaf, floats: [], + agentWaits: {}, } } @@ -1002,6 +1011,23 @@ export function agentTabId(uuid: string): string { return `${AGENT_TAB_PREFIX}${uuid}` } +/** 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 +} + /** * Reconcile the sidebar's agent-terminal tabs with the host's live list. * The host pushes the current list of agent terminals (created by the model @@ -1016,7 +1042,7 @@ export function agentTabId(uuid: string): string { */ export function reconcileAgentTerminals( state: SidebarState, - agentTerminals: ReadonlyArray<{ uuid: string; title: string }>, + agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>, ): SidebarState { const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs) .concat(state.floats.map(float => float.tab)) @@ -1031,7 +1057,17 @@ export function reconcileAgentTerminals( // convergence: no title suffix, no meta write — the tab keeps its uuid // so a later reconcile push revives it if the agent reopens the same one). const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined) - if (toAdd.length === 0 && toRemove.length === 0) return state + // 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 // Remove tabs whose uuids vanished from the server list (the agent closed // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup; // a FLOATED agent terminal leaves with its window. @@ -1057,7 +1093,7 @@ export function reconcileAgentTerminals( } next = openTabInActivePane(next, tab) } - return next + return { ...next, agentWaits: serverWaits } } // ── The per-session store ────────────────────────────────────────────────── @@ -1299,6 +1335,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { bottomOpenedOnce: record.bottomOpenedOnce === true, bottomSplits, floats, + // The agent wait state is TRANSIENT (like revealed): never restored from + // storage — the host's first push after attach repopulates it. + agentWaits: {}, } } diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts index e9fdf65..bc20e5e 100644 --- a/tests/agent-terminal-reconcile.spec.ts +++ b/tests/agent-terminal-reconcile.spec.ts @@ -126,4 +126,24 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) // new, added }) }) + + 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) + }) }) From c4c856a4508b3cba0fe3d8bfc73cb1c83fd8266a Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:37:08 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat(sidebar):=20agent=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E7=AD=89=E5=BE=85=20banner=EF=BC=88needle=20+=20?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=AD=89=E5=BE=85=E6=8C=89=E9=92=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/TerminalView.tsx | 28 ++++++++++++++ src/client/TerminalWaitBanner.tsx | 32 +++++++++++++++ src/client/api.ts | 4 ++ src/client/locales-ar.ts | 2 + src/client/locales-de.ts | 2 + src/client/locales-fr.ts | 2 + src/client/locales-hi.ts | 2 + src/client/locales-id.ts | 2 + src/client/locales-it.ts | 2 + src/client/locales-ja.ts | 2 + src/client/locales-ko.ts | 2 + src/client/locales-nl.ts | 2 + src/client/locales-pl.ts | 2 + src/client/locales-pt.ts | 2 + src/client/locales-ru.ts | 2 + src/client/locales-sv.ts | 2 + src/client/locales-th.ts | 2 + src/client/locales-tr.ts | 2 + src/client/locales-vi.ts | 2 + src/client/locales-zh-HK.ts | 2 + src/client/locales-zh-MO.ts | 2 + src/client/locales-zh-TW.ts | 2 + src/client/locales.ts | 4 ++ src/client/sidebar.module.css | 23 +++++++++++ tests/terminal-wait-banner.spec.tsx | 60 +++++++++++++++++++++++++++++ 25 files changed, 189 insertions(+) create mode 100644 src/client/TerminalWaitBanner.tsx create mode 100644 tests/terminal-wait-banner.spec.tsx diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index ab03083..ff99278 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -48,6 +48,7 @@ import { shouldActivateTerminalLink, openTerminalUrl, } from './terminal-links.ts' +import { TerminalWaitBanner } from './TerminalWaitBanner.tsx' import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ @@ -123,6 +124,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: const [fatal, setFatal] = useState(null) const [depsFatal, setDepsFatal] = useState(null) const [lastUrl, setLastUrl] = useState(null) + // 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]) const connectRef = useRef<(() => void) | null>(null) useEffect(() => { @@ -386,6 +408,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: return (
+ {agentUuid !== null && waiting !== undefined && ( + { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }} + /> + )} {depsFatal !== null && ( { setDepsFatal(null); connectRef.current?.() }} /> )} diff --git a/src/client/TerminalWaitBanner.tsx b/src/client/TerminalWaitBanner.tsx new file mode 100644 index 0000000..6d0c0fa --- /dev/null +++ b/src/client/TerminalWaitBanner.tsx @@ -0,0 +1,32 @@ +/** + * 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) })} + + +
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index cf0cacb..677c44b 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -346,6 +346,10 @@ export const api = { /** Release an agent terminal by uuid (tab closed while WS was down). */ agentPtyClose: (uuid: string) => call<{ ok: true }>('agent-pty.close', { uuid }), + /** 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 }), /** Terminal dependency status (issue #140): after a WS close 1011 with * reason `pty-deps-missing` the view fetches the full repair details here * (the close reason itself is capped at 123 bytes). */ diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index cc66eec..de1d0f4 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -104,6 +104,8 @@ export const ar: Record = { terminalError: 'فشل اتصال الطرفية', terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات', terminalRetry: 'إعادة المحاولة', + terminalWaitBanner: 'الوكيل ينتظر {needle}', + terminalSkipWait: 'تخطي الانتظار', terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty', terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):', terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index d34b382..2b5b2a9 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -89,6 +89,8 @@ export const de: Record = { terminalError: 'Terminalverbindung fehlgeschlagen', terminalConnectFailed: 'Terminal konnte wiederholt keine Verbindung herstellen', terminalRetry: 'Erneut versuchen', + terminalWaitBanner: 'Agent wartet auf {needle}', + terminalSkipWait: 'Warten überspringen', terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden', terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):', terminalDepsProfile: ' (erkanntes Profil: {profile})', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 567fffd..f1f240c 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -96,6 +96,8 @@ export const fr: Record = { terminalError: 'Échec de la connexion au terminal', terminalConnectFailed: 'Le terminal a échoué à se connecter à plusieurs reprises', terminalRetry: 'Réessayer', + terminalWaitBanner: 'L’agent attend {needle}', + terminalSkipWait: 'Ignorer l’attente', terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty', terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :', terminalDepsProfile: ' (profil détecté : {profile})', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 3c0ee0b..1710368 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -103,6 +103,8 @@ export const hi: Record = { terminalError: 'टर्मिनल कनेक्शन विफल', terminalConnectFailed: 'टर्मिनल बार-बार कनेक्ट विफल', terminalRetry: 'पुनः प्रयास', + terminalWaitBanner: 'एजेंट {needle} की प्रतीक्षा कर रहा है', + terminalSkipWait: 'प्रतीक्षा छोड़ें', terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल', terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):', terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 45a1002..d61b886 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -101,6 +101,8 @@ export const id: Record = { terminalError: 'Koneksi terminal gagal', terminalConnectFailed: 'Terminal gagal terhubung berulang kali', terminalRetry: 'Coba lagi', + terminalWaitBanner: 'Agen menunggu {needle}', + terminalSkipWait: 'Lewati penungguan', terminalDepsFailed: 'Dependensi terminal node-pty gagal dimuat', terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):', terminalDepsProfile: ' (profil terdeteksi: {profile})', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index a7ab143..8635e2d 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -94,6 +94,8 @@ export const it: Record = { terminalError: 'Connessione del terminale non riuscita', terminalConnectFailed: 'Il terminale non è riuscito a connettersi più volte', terminalRetry: 'Riprova', + terminalWaitBanner: 'L’agente è in attesa di {needle}', + terminalSkipWait: 'Salta attesa', terminalDepsFailed: 'Caricamento della dipendenza del terminale node-pty non riuscito', terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):', terminalDepsProfile: ' (rilevato profilo: {profile})', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index a967059..a20f693 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -103,6 +103,8 @@ export const ja: Record = { terminalError: 'ターミナル接続に失敗', terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました', terminalRetry: '再試行', + terminalWaitBanner: 'エージェントが {needle} を待機中', + terminalSkipWait: '待機をスキップ', terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗', terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):', terminalDepsProfile: '(検出された profile:{profile})', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 4e8b4f1..a0907be 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -95,6 +95,8 @@ export const ko: Record = { terminalError: '터미널 연결 실패', terminalConnectFailed: '터미널 연결이 여러 번 실패했습니다', terminalRetry: '다시 시도', + terminalWaitBanner: '에이전트가 {needle} 대기 중', + terminalSkipWait: '대기 건너뛰기', terminalDepsFailed: '터미널 의존성 node-pty 로드 실패', terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):', terminalDepsProfile: ' (profile 감지됨: {profile})', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 8106e22..736fac0 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -101,6 +101,8 @@ export const nl: Record = { terminalError: 'Terminalverbinding mislukt', terminalConnectFailed: 'Terminalverbinding herhaaldelijk mislukt', terminalRetry: 'Opnieuw proberen', + terminalWaitBanner: 'Agent wacht op {needle}', + terminalSkipWait: 'Wachten overslaan', terminalDepsFailed: 'Terminalafhankelijkheid node-pty kon niet worden geladen', terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):', terminalDepsProfile: ' (gedetecteerde profile: {profile})', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index dd62423..4eef5eb 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -105,6 +105,8 @@ export const pl: Record = { terminalError: 'Połączenie z terminalem nie powiodło się', terminalConnectFailed: 'Terminal nie mógł połączyć się wielokrotnie', terminalRetry: 'Ponów', + terminalWaitBanner: 'Agent czeka na {needle}', + terminalSkipWait: 'Pomiń oczekiwanie', terminalDepsFailed: 'Zależność terminala node-pty nie mogła się załadować', terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):', terminalDepsProfile: ' (wykryty profil: {profile})', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 08c0d25..7197677 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -86,6 +86,8 @@ export const pt: Record = { terminalError: 'Falha na conexão do terminal', terminalConnectFailed: 'O terminal falhou ao conectar repetidamente', terminalRetry: 'Tentar novamente', + terminalWaitBanner: 'O agente está aguardando {needle}', + terminalSkipWait: 'Pular espera', terminalDepsFailed: 'A dependência do terminal node-pty falhou ao carregar', terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):', terminalDepsProfile: ' (perfil detectado: {profile})', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 10711b8..86b3b7d 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -99,6 +99,8 @@ export const ru: Record = { terminalError: 'Сбой подключения терминала', terminalConnectFailed: 'Терминал не смог подключиться после нескольких попыток', terminalRetry: 'Повторить', + terminalWaitBanner: 'Агент ожидает {needle}', + terminalSkipWait: 'Пропустить ожидание', terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty', terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):', terminalDepsProfile: ' (обнаружен profile: {profile})', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 8c1262f..b8ad6a4 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -86,6 +86,8 @@ export const sv: Record = { terminalError: 'Terminalanslutning misslyckades', terminalConnectFailed: 'Terminal misslyckades att ansluta upprepade gånger', terminalRetry: 'Försök igen', + terminalWaitBanner: 'Agenten väntar på {needle}', + terminalSkipWait: 'Hoppa över väntan', terminalDepsFailed: 'Terminalberoendet node-pty kunde inte laddas', terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):', terminalDepsProfile: ' (upptäckt profil: {profile})', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index fcdfa5d..820baa8 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -103,6 +103,8 @@ export const th: Record = { terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว', terminalConnectFailed: 'เทอร์มินัลเชื่อมต่อล้มเหลวหลายครั้ง', terminalRetry: 'ลองอีกครั้ง', + terminalWaitBanner: 'เอเจนต์กำลังรอ {needle}', + terminalSkipWait: 'ข้ามการรอ', terminalDepsFailed: 'การอ้างอิงเทอร์มินัล node-pty โหลดล้มเหลว', terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):', terminalDepsProfile: ' (ตรวจพบ profile: {profile})', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index f7d015e..843dd67 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -103,6 +103,8 @@ export const tr: Record = { terminalError: 'Terminal bağlantısı başarısız', terminalConnectFailed: 'Terminal art arda bağlanamadı', terminalRetry: 'Yeniden dene', + terminalWaitBanner: 'Ajan {needle} bekliyor', + terminalSkipWait: 'Beklemeyi atla', terminalDepsFailed: 'Terminal bağımlılığı node-pty yüklenemedi', terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):', terminalDepsProfile: ' (algılanan profil: {profile})', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 11044a8..34e9570 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -103,6 +103,8 @@ export const vi: Record = { terminalError: 'Kết nối terminal thất bại', terminalConnectFailed: 'Terminal kết nối thất bại nhiều lần', terminalRetry: 'Thử lại', + terminalWaitBanner: 'Agent đang chờ {needle}', + terminalSkipWait: 'Bỏ qua thời gian chờ', terminalDepsFailed: 'Dependency terminal node-pty tải thất bại', terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):', terminalDepsProfile: ' (phát hiện profile: {profile})', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index b898c5a..f21ac38 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -118,6 +118,8 @@ export const zhHK: Record = { terminalError: '終端連線失敗', terminalConnectFailed: '終端多次連線失敗', terminalRetry: '重試', + terminalWaitBanner: 'Agent 正在等待 {needle}', + terminalSkipWait: '跳過等待', terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 52d3e8d..8270392 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -118,6 +118,8 @@ export const zhMO: Record = { terminalError: '終端連線失敗', terminalConnectFailed: '終端多次連線失敗', terminalRetry: '重試', + terminalWaitBanner: 'Agent 正在等待 {needle}', + terminalSkipWait: '跳過等待', terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 0601580..1acab65 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -118,6 +118,8 @@ export const zhTW: Record = { terminalError: '終端連線失敗', terminalConnectFailed: '終端多次連線失敗', terminalRetry: '重試', + terminalWaitBanner: 'Agent 正在等待 {needle}', + terminalSkipWait: '跳過等待', terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', diff --git a/src/client/locales.ts b/src/client/locales.ts index 2c0083e..94f223c 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -115,6 +115,8 @@ export const zh = { terminalError: '终端连接失败', terminalConnectFailed: '终端多次连接失败', terminalRetry: '重试', + terminalWaitBanner: 'Agent 正在等待 {needle}', + terminalSkipWait: '跳过等待', terminalDepsFailed: '终端依赖 node-pty 加载失败', terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):', terminalDepsProfile: '(检测到 profile:{profile})', @@ -565,6 +567,8 @@ export const en: Record = { terminalError: 'Terminal connection failed', terminalConnectFailed: 'Terminal failed to connect repeatedly', terminalRetry: 'Retry', + terminalWaitBanner: 'Agent is waiting for {needle}', + terminalSkipWait: 'Skip wait', terminalDepsFailed: 'Terminal dependency node-pty failed to load', terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):', terminalDepsProfile: ' (detected profile: {profile})', diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 3694a6b..eb70711 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -2201,6 +2201,29 @@ body[data-dsh-sidebar-dragging] .bottomPanel { opacity: 0.85; } +/* 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; +} + .boundaryError { position: fixed; right: 0; diff --git a/tests/terminal-wait-banner.spec.tsx b/tests/terminal-wait-banner.spec.tsx new file mode 100644 index 0000000..a34094e --- /dev/null +++ b/tests/terminal-wait-banner.spec.tsx @@ -0,0 +1,60 @@ +/** + * 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) + // The needle's inline contribution is capped (NEEDLE_DISPLAY_CAP): at + // most 80 X's are rendered — the full 200-char run only rides the title + // tooltip. The localized sentence around the needle is not measured; + // its length varies per language, so counting the needle's own chars is + // the language-agnostic form of the display-cap assertion. + expect((span!.textContent?.match(/X/g) ?? []).length).toBeLessThanOrEqual(80) + }) +}) From b3ebe52c3e824b92b8d2e3ac3026072af5d77ba8 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:43:29 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat(sidebar):=20agent=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=20tab=20=E6=A0=87=E9=A2=98=E5=9C=A8=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E6=97=B6=E6=98=BE=E7=A4=BA=20=E2=8F=B3=20=E5=BE=BD=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/Sidebar.tsx | 9 +++ tests/agent-wait-badge.spec.tsx | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/agent-wait-badge.spec.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index f20ce7a..cb982be 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -826,6 +826,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * strip must never break because a plugin's badge computation failed. */ const tabBadgeOf = (tab: SidebarTab): ReactNode => { + // 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 {'⏳'} + } const descriptor = ctx.get('betterSidebar')?.getTab(tab.type) if (descriptor?.badge === undefined) return null let value: string | number | null | undefined diff --git a/tests/agent-wait-badge.spec.tsx b/tests/agent-wait-badge.spec.tsx new file mode 100644 index 0000000..a92337a --- /dev/null +++ b/tests/agent-wait-badge.spec.tsx @@ -0,0 +1,127 @@ +/** + * 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) + } +} + +/** Unique per-test session ids (see the comment inside mountSidebar). */ +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, + }) + // Unique session per test — the store persists per-session state to + // localStorage (200ms debounce); a shared id lets a previous test's late + // write leak into this store's setSession restore. + const sessionId = `s1-${++sessionSeq}` + store.setSession(sessionId) + 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() + }, + } +} + +afterEach(() => { + document.body.innerHTML = '' + // Belt and braces (same as bottom-auto-terminal): drop any persisted layout + // a pending 200ms debounce write left behind between tests. Guarded — some + // node/jsdom combos (node >=22 without --localstorage-file) expose no + // working localStorage and a bare access would throw in the hook. + if (typeof localStorage !== 'undefined') 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('⏳') + }) +}) From 8c452104e5d7011ce9e7829776f872f026034f62 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 13:47:05 +0800 Subject: [PATCH 09/13] =?UTF-8?q?docs(plans):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E7=AD=89=E5=BE=85=20banner=20=E5=AE=9E=E6=96=BD=E5=81=8F?= =?UTF-8?q?=E5=B7=AE=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-09-terminal-wait-banner-design.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/plans/2026-09-09-terminal-wait-banner-design.md b/docs/plans/2026-09-09-terminal-wait-banner-design.md index dd4bfd7..d218705 100644 --- a/docs/plans/2026-09-09-terminal-wait-banner-design.md +++ b/docs/plans/2026-09-09-terminal-wait-banner-design.md @@ -118,3 +118,27 @@ needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到 - 不新增独立 waits WebSocket(方案 B 否决); - 不改 `ctx.betterSidebar` 服务面(`external-plugin-guide.md` 无需同步); - 不做乐观 UI(banner 消失以服务端推送为准)。 + +## 实施偏差记录 + +- **⏳ tab 徽章不走 `TabDescriptor.badge`**:实现时核实该 API 是 type-keyed + (`badge(ctx, scope, state)`,无 tab 参数、external 插件共享),无法定位单个 + tab。改为 Sidebar 壳层 `tabBadgeOf` 的 sidebar-internal 特例(agent: 前缀判断 + + `state.agentWaits` 查表),`service.ts` 与 external-plugin-guide.md 零改动。 +- **banner 抽为独立组件 `TerminalWaitBanner.tsx`**:设计原文写在 TerminalView + 内;抽出后无 xterm 依赖,jsdom 单测可直接渲染(TerminalView 仍负责订阅 store + 并把 onSkip 接到 `api.agentSkipWait`)。 +- **词典同步扩到全部 21 个语言文件**:设计只写了 zh/en/ja,但 + `tests/locales.spec.ts` 断言每个第三方词典与 zh 键集相等——只同步三语会让该 + 守护测试挂掉。按仓库惯例(全语言同步,如 e8761ee)补齐其余 18 语言同两键。 +- **banner 配色**:仓库 CSS 无 state-info 令牌,沿用 `.terminalBanner` 的 warn + 对(`--dsw-alias-state-warn-label/-tertiary`),符合皮肤契约。 +- **设计文档与计划随 feat 分支进 PR**:main 受分支保护,纯文档直推被 hook 拒绝 + (`4a6a77a` / `2448068` 随本分支合并)。 +- **测试断言以文本计数取代 CSS 类名**:CSS module 类名在测试变换下不稳定, + ⏳ 数量断言改从 `textContent` 计数;长 needle 截断断言改为计量 needle 自身 + 贡献字符数(本地化句子包裹 needle,整体长度断言与语言相关,原计划内部矛盾)。 +- **新测试的 localStorage 兜底**:Node 26 + jsdom 29 组合下测试环境可能无全局 + `localStorage`(上游 `bottom-auto-terminal.spec.tsx` 在同环境即失败,属已知 + win32/环境非回归类);新 spec 的 afterEach 加 `typeof localStorage` 守卫, + 上游测试一律未动。 From 9ce3c2706acb6c87ff1f395ffd63ccd1b5124f60 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 14:08:29 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(sidebar):=20=E7=AD=89=E5=BE=85=20bann?= =?UTF-8?q?er=20=E5=AD=97=E4=BD=93=E7=BB=A7=E6=89=BF=20UI=20=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=EF=BC=8C=E5=8E=BB=E6=8E=89=E6=95=B4=E5=8F=A5=20monosp?= =?UTF-8?q?ace=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/sidebar.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index eb70711..a287af2 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -2221,7 +2221,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .boundaryError { From 9a2c56eb42c5c5258b3474b602bb038aee3e8749 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 9 Sep 2026 14:29:44 +0800 Subject: [PATCH 11/13] =?UTF-8?q?test:=20=E6=8C=89=20Copilot=20review=20?= =?UTF-8?q?=E4=BF=AE=E6=B5=8B=E8=AF=95=E5=81=A5=E5=A3=AE=E6=80=A7=EF=BC=88?= =?UTF-8?q?localStorage=20try/catch=E3=80=81=E5=8A=A8=E6=80=81=20uuid?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E8=AE=B0=E5=BD=95=20pinned=20=E8=B7=A8?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-09-terminal-wait-banner-design.md | 10 +++++++++- tests/agent-wait-badge.spec.tsx | 13 +++++++++---- tests/tools.spec.ts | 5 +++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-09-09-terminal-wait-banner-design.md b/docs/plans/2026-09-09-terminal-wait-banner-design.md index d218705..012234a 100644 --- a/docs/plans/2026-09-09-terminal-wait-banner-design.md +++ b/docs/plans/2026-09-09-terminal-wait-banner-design.md @@ -140,5 +140,13 @@ needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到 贡献字符数(本地化句子包裹 needle,整体长度断言与语言相关,原计划内部矛盾)。 - **新测试的 localStorage 兜底**:Node 26 + jsdom 29 组合下测试环境可能无全局 `localStorage`(上游 `bottom-auto-terminal.spec.tsx` 在同环境即失败,属已知 - win32/环境非回归类);新 spec 的 afterEach 加 `typeof localStorage` 守卫, + win32/环境非回归类);新 spec 的 afterEach 加守卫 + try/catch(jsdom opaque + origin 下 localStorage 可以是 throwing accessor,裸 typeof 也会炸), 上游测试一律未动。 +- **已知限制:跨会话 pinned 虚拟 tab 收不到实时 banner/⏳**(Copilot review + 指出):等待状态 feed 按 viewer 会话订阅(`/sidebar/ws/agent-terminals` 按 + 当前 sessionId 连接),钉到其他会话的 pinned 虚拟 agent tab + (`pinned::agent:`)在其 home 会话非当前会话时收不到 + 实时等待状态。修复需跨会话 wait feed 多路复用(连 home 会话推送或聚合 + 推送),超出本特性范围,留待后续设计;跳过 API 按 uuid 全局寻址,跨会话 + 跳过本身可达。 diff --git a/tests/agent-wait-badge.spec.tsx b/tests/agent-wait-badge.spec.tsx index a92337a..5dd4cf2 100644 --- a/tests/agent-wait-badge.spec.tsx +++ b/tests/agent-wait-badge.spec.tsx @@ -80,10 +80,15 @@ function mountSidebar(): { container: HTMLDivElement; store: ReturnType { document.body.innerHTML = '' // Belt and braces (same as bottom-auto-terminal): drop any persisted layout - // a pending 200ms debounce write left behind between tests. Guarded — some - // node/jsdom combos (node >=22 without --localstorage-file) expose no - // working localStorage and a bare access would throw in the hook. - if (typeof localStorage !== 'undefined') localStorage.clear() + // a pending 200ms debounce write left behind between tests. Fully guarded — + // some node/jsdom combos expose no working localStorage, and an opaque + // origin can define it as a THROWING accessor, so even `typeof` can throw; + // the try/catch keeps the cleanup hook itself failure-proof. + try { + if (typeof localStorage !== 'undefined') localStorage.clear() + } catch { + // Opaque origin / no storage: nothing persisted to clear. + } vi.unstubAllGlobals() }) diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts index 197f5b4..c83f004 100644 --- a/tests/tools.spec.ts +++ b/tests/tools.spec.ts @@ -203,9 +203,10 @@ describe('agent terminal tools', () => { 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.create('s1', 'skipper', '') + // Use the uuid the registry actually minted — no coupling to its id scheme. + const uuid = registry.create('s1', 'skipper', '') registry.nextWaitResult = { kind: 'skipped' } - const value = await tool.execute({ uuid: 'uuid-1', needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1')) + const value = await tool.execute({ uuid, needle: 'BUILD_OK', timeout_ms: 1000 }, exec('s1')) expect(value).toEqual({ kind: 'skipped', needle: 'BUILD_OK' }) expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) }) From 70a150794bf40676c8008c06563b77367a2c908c Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Thu, 10 Sep 2026 21:28:22 +0800 Subject: [PATCH 12/13] fix: address Copilot review round 3 (code-point truncation, a11y live status, disabled-tab wait mirroring, test unmount, concurrent-wait test) --- .../2026-09-09-terminal-wait-banner-design.md | 15 ++++++++ src/client/TerminalWaitBanner.tsx | 17 +++++++-- src/client/sidebar/use-host-feeds.ts | 9 ++++- src/client/state.ts | 37 ++++++++++++++++--- tests/agent-pty.spec.ts | 25 +++++++++++++ tests/agent-wait-badge.spec.tsx | 22 +++++++---- 6 files changed, 106 insertions(+), 19 deletions(-) diff --git a/docs/plans/2026-09-09-terminal-wait-banner-design.md b/docs/plans/2026-09-09-terminal-wait-banner-design.md index 012234a..b7fd64d 100644 --- a/docs/plans/2026-09-09-terminal-wait-banner-design.md +++ b/docs/plans/2026-09-09-terminal-wait-banner-design.md @@ -150,3 +150,18 @@ needle 出现 / 超时 / 终端退出。等待期间用户在侧边栏看不到 实时等待状态。修复需跨会话 wait feed 多路复用(连 home 会话推送或聚合 推送),超出本特性范围,留待后续设计;跳过 API 按 uuid 全局寻址,跨会话 跳过本身可达。 +- **Copilot review 第三轮修复(合并 rc.1 后扫描)**: + 1. `truncateNeedle` 改按 Unicode 码点截断(`Array.from`),原 UTF-16 码元 + 截断会在截断点劈开代理对(emoji 渲染为替换字符); + 2. banner 消息加 `role="status"` + `aria-live="polite"`,完整未截断的 + 本地化消息经 `aria-label` 提供给读屏器(不新增词典键); + 3. `terminal` tab 类型禁用时推送处理从整体跳过改为 `mirrorAgentWaits` + ——仍镜像权威 `waiting` map(等在禁用窗口内 resolve 也清 banner 状态), + 只跳过 tab 增删 reconcile,否则重启用后已开终端残留 stale banner/⏳ + 直到无关推送到达; + 4. badge 测试跟踪挂载的 root 并在 `afterEach` 里先 unmount 再清 DOM + (裸 `innerHTML = ''` 不触发 React effect 清理,store 订阅/host-feed + effect 会泄漏进后续测试); + 5. 补并发双 `waitFor` 同 uuid 测试:快照显示最新 needle(`waits.at(-1)`)、 + `skipWait` 返回 2、两个 promise 都以各自 needle `skipped` 收敛、快照 + 清空且 skipWait 幂等归零。 diff --git a/src/client/TerminalWaitBanner.tsx b/src/client/TerminalWaitBanner.tsx index 6d0c0fa..5bc5add 100644 --- a/src/client/TerminalWaitBanner.tsx +++ b/src/client/TerminalWaitBanner.tsx @@ -12,16 +12,27 @@ 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). */ +/** Truncate one needle for inline display (title attr carries the full text). + * Cut by Unicode code points, not UTF-16 code units — a naive slice can split + * a surrogate pair at the cutoff and render a dangling replacement char. */ export function truncateNeedle(needle: string): string { - return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle + const points = Array.from(needle) + return points.length > NEEDLE_DISPLAY_CAP + ? `${points.slice(0, NEEDLE_DISPLAY_CAP - 1).join('')}…` + : needle } export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) { const { needle, onSkip } = props return (
- + {t('terminalWaitBanner', { needle: truncateNeedle(needle) })}