Merge pull request #606 from omdsh-dev/feat/wait-banner

feat(terminal): 等待 banner + 跳过等待(terminal_wait_for 增 skipped)
This commit is contained in:
Atlas Zeng
2026-09-11 11:28:21 +08:00
committed by GitHub
37 changed files with 1860 additions and 31 deletions
@@ -0,0 +1,192 @@
# 终端等待 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 轮询
天然承载跳过延迟,无需事件唤醒。与终端退出在同一 tick 竞争时**退出优先**(见实施
偏差记录)。
- 新方法 `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 "<needle>" — 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<uuid, { needle: string; since: number }>`(默认
`{}`;等待结束即删除键);
- `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 消失以服务端推送为准)。
## 实施偏差记录
- **⏳ 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 加守卫 + 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:<homeSessionId>:agent:<uuid>`)在其 home 会话非当前会话时收不到
实时等待状态。修复需跨会话 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 幂等归零。
- **轮询优先级:终端退出优先于用户跳过**(v0.19.0 发版前 review 发现):设计原文
写「每圈优先检查 `skipped`」,实现是 `aborted → handle.exited → record.skipped`,
即 pty 退出与用户点跳过落进同一个 50ms tick 时返回 `exited`。**维持实现现状**,
设计原文已同步为「退出优先」:两种顺序下用户可见结果完全相同(banner 消失、
`skipWait` 返回 0、等待结束),只有工具结果的 `kind` 文案不同;而进程已死是
客观事实,报 `exited` 不撒谎,工具结果本身也引导 agent 用 `terminal_read` 复核。
若日后要让「用户意图优先」,改动点是循环内两行先后 + 一条同 tick 竞态测试。
- **两个已知限制的补充记录**(同一次 review):
1. **pinned tab 的 banner 与 ⏳ 不对称**:pinned 虚拟 id 是
`pinned:<homeSessionId>:agent:<uuid>`,`isAgentTabId` 只认 `agent:` 前缀,因此
钉住的 agent tab **不会误挂 ⏳**(比"收不到"更强的保证);但 Workbench 渲染
pinned tab 时给 TerminalView 传的是**原 tab id + home 作用域**,所以 home 会话
正在等待时该视图**会**显示 banner,而它的 strip 徽章仍不出现。两个信号数据源
不同(banner 读 home store,徽章读 viewer 的 `agentWaits`)。
2. **⏳ 到不了 DSH 原生右侧栏**:徽章经 `getTabBadge` 渲染,唯一消费点是插件
自绘的 `TabBar`;原生栏的 `NativeTabTitle` 契约只带 title,不带 badge。agent
终端标签页走 `openTabInBottomPane`(底部工作台),故 agent 流程可见;这是原生
承载面契约决定的上限,非本特性缺陷。
- **注释随实现同步**(v0.19.0 发版前 review):`use-host-feeds.ts` effect 头部原写
「terminal tab 类型禁用时 pushes are ignored」,与上面第 3 项的 `mirrorAgentWaits`
改法矛盾,已改为「不增删 tab,但仍镜像权威 wait map」;`src/index.ts` 的
`agent-pty.skip-wait` 注释原文把「无活动等待 → 0」泛化成了「幂等」,已收窄为
「无活动等待返回 `{skipped:0}`;未知 uuid 走 `expect` → 404(已退出的终端即属此
列),客户端 `.catch` 容忍并等下一次推送收敛」。
@@ -0,0 +1,980 @@
# 终端等待 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 <file>` 验证;最终任务必须 `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<string, { needle: string; since: number }>`(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<string, { needle: string; since: number }>
```
(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<string, { needle: string; since: number }> = {}
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<string, { needle: string; since: number }>,
): 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 (
<div className={css.terminalWaitBanner}>
<span className={css.terminalWaitNeedle} title={needle}>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
```
(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 中 `<div className={css.terminalWrap}>` 的第一个子元素位置(`depsFatal` 条目之前)加:
```tsx
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/terminal-wait-banner.spec.tsx`
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
git add src/client/TerminalWaitBanner.tsx src/client/TerminalView.tsx src/client/api.ts src/client/locales.ts src/client/locales-ja.ts src/client/sidebar.module.css tests/terminal-wait-banner.spec.tsx
git commit -m "feat(sidebar): agent 终端等待 banner(needle + 跳过等待按钮)"
```
---
### Task 5: tab 标题 ⏳ 徽章(shell 层特例)+ jsdom 链路测试
**Files:**
- Modify: `src/client/Sidebar.tsx`(`tabBadgeOf` ~828-841 行)
- Test: `tests/agent-wait-badge.spec.tsx`(新文件,harness 照抄 `tests/bottom-auto-terminal.spec.tsx` 1-97 行的模式)
**Interfaces:**
- Consumes: Task 3 的 `state.agentWaits`;`isAgentTabId` / `agentUuidOf`(Sidebar.tsx 已 import,第 38-39 行)。
- Produces: agent tab 的 ⏳ 徽章 pill(复用 `css.tabBadge`);推送 → 状态 → 徽章全链路。
- [ ] **Step 1: 写失败测试(新文件 `tests/agent-wait-badge.spec.tsx`)**
完整文件(FakeWebSocket 记录 url 与实例,驱动 `/sidebar/ws/agent-terminals` 推送;`registerStubTerminal` 防止懒加载 chunk 在 jsdom 里拉真实 bundle):
```tsx
/**
* The agent-terminal wait badge chain, end to end in jsdom: the host's
* agent-terminals push (mirroring the real /sidebar/ws/agent-terminals
* frames) reconciles tabs AND the wait map; the shell's tabBadgeOf renders
* the hourglass pill on the agent tab while a wait is live and drops it
* when the push clears. Harness mirrors tests/bottom-auto-terminal.spec.tsx
* (real Sidebar shell + fake context + stubbed WebSocket).
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, openTabInActivePane } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
class FakeWebSocket {
static instances: FakeWebSocket[] = []
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = (): void => {}
constructor(readonly url: string) {
FakeWebSocket.instances.push(this)
}
}
let sessionSeq = 0
function mountSidebar(): { container: HTMLDivElement; store: ReturnType<typeof createSidebarStore>; service: BetterSidebarService; unmount: () => void } {
vi.stubGlobal('WebSocket', FakeWebSocket)
FakeWebSocket.instances = []
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Replace the terminal descriptor with a stub: the real one lazy-loads the
// xterm chunk, which jsdom cannot fetch — the badge under test renders in
// the TAB STRIP, independent of the view component.
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// Stub the terminal descriptor: the real one lazy-loads the xterm chunk,
// which jsdom cannot fetch (the bottom-auto-terminal harness does the same).
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => null,
})
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: sessionId,
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
return {
container,
store,
service,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
let sessionSeq = 0
afterEach(() => {
document.body.innerHTML = ''
localStorage.clear()
vi.unstubAllGlobals()
})
/** The agent-terminals push socket the shell opened for the current session. */
function feedsSocket(): FakeWebSocket {
const socket = FakeWebSocket.instances.find(candidate => candidate.url.includes('/sidebar/ws/agent-terminals'))
if (socket === undefined) throw new Error('agent-terminals socket not connected')
return socket
}
describe('agent terminal wait badge (push → state → tab pill)', () => {
it('shows ⏳ on the agent tab while a wait is active and drops it when it ends', () => {
const { container } = mountSidebar()
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
// First push: the agent tab lands in the active pane.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).toContain('dev server')
expect(container.textContent).not.toContain('⏳')
// Second push carries a live wait → the hourglass pill appears.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
expect(container.textContent).toContain('⏳')
// The wait resolved (skip/exit/abort all converge to a waiting-less push).
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
it('shows ⏳ only on agent tabs, never on UI-owned terminal tabs', () => {
const { container, store } = mountSidebar()
// A UI-owned terminal tab (id NOT agent:) lives in the strip too; the
// badge lookup keys on the agent uuid, so it never badges.
act(() => { store.reduce(s => openTabInActivePane(s, { id: 'terminal:manual-1', type: 'terminal', title: 'UI terminal' })) })
const terminal = { uuid: 'u1', title: 'dev server', command: '', exited: false }
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([{ ...terminal, waiting: { needle: 'READY_1', since: 1 } }]) }) })
// Exactly ONE hourglass: the agent tab. (Counted from textContent — CSS
// module class names are not stable under the test transform.)
expect((container.textContent.match(/⏳/g) ?? []).length).toBe(1)
// The wait resolved → the pill disappears entirely.
act(() => { feedsSocket().onmessage?.({ data: JSON.stringify([terminal]) }) })
expect(container.textContent).not.toContain('⏳')
})
})
```
> 实现注意:`sessionSeq` 声明放 `mountSidebar` 之前(与 bottom-auto-terminal.spec.tsx 相同的模块级写法);`createSidebarStore` 返回类型直接用 `ReturnType<typeof createSidebarStore>`,勿手写接口。
- [ ] **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 <span className={css.tabBadge}>{'⏳'}</span>
}
```
(`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。
+89 -20
View File
@@ -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,69 @@ 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 {
// Exit outranks a skip in the same tick: `skipWait` flips `record.skipped`
// and the next poll observes it, but a pty that died first is the
// objective fact (a skip is moot once the process is gone). The user
// sees the same three things either way — the banner clears, `skipWait`
// reports 0 for the resolved wait, and the tool result says the wait
// ended — only the reported `kind` differs (see the design doc's
// implementation-deviation record).
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.
*
+9
View File
@@ -641,6 +641,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 <span className={css.tabBadge}>{'⏳'}</span>
}
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
+28
View File
@@ -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<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(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 (
<div className={css.terminalWrap}>
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
+43
View File
@@ -0,0 +1,43 @@
/**
* 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).
* 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 {
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 (
<div className={css.terminalWaitBanner}>
<span
className={css.terminalWaitNeedle}
title={needle}
role="status"
aria-live="polite"
aria-label={t('terminalWaitBanner', { needle })}
>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
+4
View File
@@ -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). */
+2
View File
@@ -107,6 +107,8 @@ export const ar: Record<string, string> = {
terminalError: 'فشل اتصال الطرفية',
terminalConnectFailed: 'فشل اتصال الطرفية عدة مرات',
terminalRetry: 'إعادة المحاولة',
terminalWaitBanner: 'الوكيل ينتظر {needle}',
terminalSkipWait: 'تخطي الانتظار',
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
+2
View File
@@ -92,6 +92,8 @@ export const de: Record<string, string> = {
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})',
+2
View File
@@ -99,6 +99,8 @@ export const fr: Record<string, string> = {
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})',
+2
View File
@@ -106,6 +106,8 @@ export const hi: Record<string, string> = {
terminalError: 'टर्मिनल कनेक्शन विफल',
terminalConnectFailed: 'टर्मिनल बार-बार कनेक्ट विफल',
terminalRetry: 'पुनः प्रयास',
terminalWaitBanner: 'एजेंट {needle} की प्रतीक्षा कर रहा है',
terminalSkipWait: 'प्रतीक्षा छोड़ें',
terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल',
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
+2
View File
@@ -104,6 +104,8 @@ export const id: Record<string, string> = {
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})',
+2
View File
@@ -97,6 +97,8 @@ export const it: Record<string, string> = {
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})',
+2
View File
@@ -106,6 +106,8 @@ export const ja: Record<string, string> = {
terminalError: 'ターミナル接続に失敗',
terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました',
terminalRetry: '再試行',
terminalWaitBanner: 'エージェントが {needle} を待機中',
terminalSkipWait: '待機をスキップ',
terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗',
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
+2
View File
@@ -98,6 +98,8 @@ export const ko: Record<string, string> = {
terminalError: '터미널 연결 실패',
terminalConnectFailed: '터미널 연결이 여러 번 실패했습니다',
terminalRetry: '다시 시도',
terminalWaitBanner: '에이전트가 {needle} 대기 중',
terminalSkipWait: '대기 건너뛰기',
terminalDepsFailed: '터미널 의존성 node-pty 로드 실패',
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
+2
View File
@@ -104,6 +104,8 @@ export const nl: Record<string, string> = {
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})',
+2
View File
@@ -108,6 +108,8 @@ export const pl: Record<string, string> = {
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})',
+2
View File
@@ -89,6 +89,8 @@ export const pt: Record<string, string> = {
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})',
+2
View File
@@ -102,6 +102,8 @@ export const ru: Record<string, string> = {
terminalError: 'Сбой подключения терминала',
terminalConnectFailed: 'Терминал не смог подключиться после нескольких попыток',
terminalRetry: 'Повторить',
terminalWaitBanner: 'Агент ожидает {needle}',
terminalSkipWait: 'Пропустить ожидание',
terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty',
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
+2
View File
@@ -89,6 +89,8 @@ export const sv: Record<string, string> = {
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})',
+2
View File
@@ -106,6 +106,8 @@ export const th: Record<string, string> = {
terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว',
terminalConnectFailed: 'เทอร์มินัลเชื่อมต่อล้มเหลวหลายครั้ง',
terminalRetry: 'ลองอีกครั้ง',
terminalWaitBanner: 'เอเจนต์กำลังรอ {needle}',
terminalSkipWait: 'ข้ามการรอ',
terminalDepsFailed: 'การอ้างอิงเทอร์มินัล node-pty โหลดล้มเหลว',
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
+2
View File
@@ -106,6 +106,8 @@ export const tr: Record<string, string> = {
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})',
+2
View File
@@ -106,6 +106,8 @@ export const vi: Record<string, string> = {
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})',
+2
View File
@@ -121,6 +121,8 @@ export const zhHK: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+2
View File
@@ -121,6 +121,8 @@ export const zhMO: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+2
View File
@@ -121,6 +121,8 @@ export const zhTW: Record<string, string> = {
terminalError: '終端連線失敗',
terminalConnectFailed: '終端多次連線失敗',
terminalRetry: '重試',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳過等待',
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
+4
View File
@@ -118,6 +118,8 @@ export const zh = {
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
terminalWaitBanner: 'Agent 正在等待 {needle}',
terminalSkipWait: '跳过等待',
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
@@ -558,6 +560,8 @@ export const en: Record<keyof typeof zh, string> = {
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})',
+22
View File
@@ -1895,6 +1895,28 @@ 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;
}
.boundaryError {
position: fixed;
right: 0;
+11 -5
View File
@@ -7,7 +7,7 @@
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import { mirrorAgentWaits, reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
@@ -94,8 +94,9 @@ export function useHostFeeds(feeds: {
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
* While the terminal tab type is disabled in settings, pushes are
* ignored (no auto-added tabs); re-enabling makes the next push converge.
* While the terminal tab type is disabled in settings, pushes add / remove
* no tabs — but the authoritative wait map is STILL mirrored (see the
* branch below); re-enabling makes the next push converge on both.
*/
useEffect(() => {
if (sessionId === undefined) return
@@ -112,10 +113,15 @@ 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
// Terminal tabs are disabled: skip tab add/remove reconciliation,
// but STILL mirror the authoritative wait map — a wait resolving
// during the disabled window must clear its banner state, or a
// re-enabled terminal keeps a stale banner until the next
// unrelated push.
? mirrorAgentWaits(s, list)
: reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
+67 -3
View File
@@ -91,6 +91,14 @@ export interface SidebarState {
bottomOpenedOnce: boolean
/** The bottom workbench's split tree. */
bottomSplits: SplitNode
/**
* 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<string, { needle: string; since: number }>
}
export const TAB_MAX_WIDTH = 160
@@ -167,6 +175,7 @@ export function makeDefaultState(): SidebarState {
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
agentWaits: {},
}
}
@@ -661,6 +670,36 @@ 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<string, { needle: string; since: number }>,
): 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
}
/** Fold the pushed terminal snapshots into the authoritative wait map. */
function serverWaitsOf(
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
): Record<string, { needle: string; since: number }> {
const serverWaits: Record<string, { needle: string; since: number }> = {}
for (const terminal of agentTerminals) {
if (terminal.waiting !== undefined && terminal.waiting !== null) {
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
}
}
return serverWaits
}
/**
* 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
@@ -675,7 +714,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.bottomSplits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
@@ -689,7 +728,12 @@ 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 = serverWaitsOf(agentTerminals)
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.
let bottomSplits = state.bottomSplits
@@ -711,7 +755,24 @@ export function reconcileAgentTerminals(
}
next = openTabInBottomPane(next, tab)
}
return next
return { ...next, agentWaits: serverWaits }
}
/**
* Mirror ONLY the authoritative agent-wait map from a push — no tab
* add/remove reconciliation. Used while the `terminal` tab type is disabled:
* the tab surface is frozen, but a wait that resolves during that window
* must still clear its banner state, or a re-enabled terminal keeps a stale
* banner/⏳ until some unrelated host event fires the next full reconcile.
* Idempotent: a no-op when the map already matches.
*/
export function mirrorAgentWaits(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
): SidebarState {
const serverWaits = serverWaitsOf(agentTerminals)
if (sameAgentWaits(state.agentWaits, serverWaits)) return state
return { ...state, agentWaits: serverWaits }
}
// ── The per-session store ──────────────────────────────────────────────────
@@ -849,6 +910,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
// The agent wait state is TRANSIENT (like revealed): never restored from
// storage — the host's first push after attach repopulates it.
agentWaits: {},
}
}
+11
View File
@@ -546,6 +546,17 @@ 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. An unknown uuid (a terminal
// already closed / reaped) goes through `expect` and surfaces as 404
// not-found; the client tolerates that and lets the next push converge.
// Nothing waiting on a live terminal is not an error: 0 skipped.
// 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
+14 -2
View File
@@ -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}.` }]
},
+102
View File
@@ -12,6 +12,7 @@ import {
snapshotOf,
tryResizePty,
} from '../src/agent-pty.ts'
import { SidebarError } from '../src/wire.ts'
/**
* Resolve a shell binary for tests: on Windows use PowerShell (available on
@@ -393,6 +394,107 @@ 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('concurrent waits on one uuid: snapshot shows the latest needle, skipWait skips all', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'concurrent', '', process.cwd(), 80, 24)
// The bare shell never emits either needle → both waits miss the fast
// paths and register their records (synchronously, before waitFor's
// first poll await — the documented concurrent-wait contract).
const first = registry.waitFor(uuid, 'NEVER_A_XYZ', 60_000)
const second = registry.waitFor(uuid, 'NEVER_B_XYZ', 60_000)
// The banner mirrors the LATEST wait (waits.at(-1)).
expect(registry.list('s1')[0]?.waiting?.needle).toBe('NEVER_B_XYZ')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
// One skip transitions EVERY active wait on the terminal.
expect(registry.skipWait(uuid)).toBe(2)
expect(await first).toEqual({ kind: 'skipped', needle: 'NEVER_A_XYZ' })
expect(await second).toEqual({ kind: 'skipped', needle: 'NEVER_B_XYZ' })
// Both resolved → the wait state cleared from the snapshot.
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
// Idempotent: nothing left to skip.
expect(registry.skipWait(uuid)).toBe(0)
} 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/)
// The HTTP layer maps `status` straight onto the response, so the 404
// contract rides this field — assert it, not just the message.
let thrown: unknown
try {
registry.skipWait('missing-uuid')
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).status).toBe(404)
expect((thrown as SidebarError).code).toBe('not-found')
} finally {
registry.disposeAll()
}
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {
+20
View File
@@ -125,4 +125,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)
})
})
+138
View File
@@ -0,0 +1,138 @@
/**
* 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, openTabInBottomPane } 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
/** Roots mounted by the current test; afterEach unmounts them (reverse
* order) so React effect cleanup (store subscription, host-feed sockets,
* debounced persistence) actually runs between tests. */
const mounted: Array<() => void> = []
function mountSidebar(): { container: HTMLDivElement; store: ReturnType<typeof createSidebarStore>; 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 })) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
mounted.push(unmount)
return { container, store, service, unmount }
}
afterEach(() => {
// Unmount BEFORE wiping the DOM: root.unmount() must run the shell's
// effect cleanups (store subscription, WS close, persistence debounce);
// document.body.innerHTML = '' alone leaves them alive into later tests.
for (const unmount of mounted.splice(0).reverse()) unmount()
document.body.innerHTML = ''
// Belt and braces (same as bottom-auto-terminal): drop any persisted layout
// 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()
})
/** 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 => openTabInBottomPane(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('⏳')
})
})
+60
View File
@@ -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)
})
})
+28 -1
View File
@@ -22,6 +22,9 @@ interface CapturedTool {
class FakeRegistry {
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
/** 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,26 @@ 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')
// 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, 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')