dsh-ssh-tui 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -35,6 +35,26 @@ Singles: [headless stdout](docs/screenshots/headless.png) · [dsh-ssh-tui](docs/
35
35
 
36
36
  ## Install
37
37
 
38
+ On a remote box with `dsh` and pnpm already installed (no clone):
39
+
40
+ ```sh
41
+ dsh plugin --profile tui add dsh-ssh-tui
42
+ # optional: reuse a SuperGrok / grok-bridge token already on the machine
43
+ dsh plugin --profile tui add github:cyjyyd/dsh-llm-xai-oauth
44
+ dsh plugin --profile headless add github:cyjyyd/dsh-llm-xai-oauth
45
+ ```
46
+
47
+ Smoke the route before opening the TUI. The TUI exits immediately without a TTY:
48
+
49
+ ```sh
50
+ dsh --profile headless "Reply with exactly: tui-install-ok. Do not use tools."
51
+ dsh --profile tui
52
+ ```
53
+
54
+ From a checkout: `bash scripts/smoke-headless.sh` (prints an outcome summary, never a token).
55
+
56
+ From git:
57
+
38
58
  ```sh
39
59
  git clone https://github.com/cyjyyd/dsh-ssh-tui.git
40
60
  cd dsh-ssh-tui
@@ -49,14 +69,6 @@ npm run build
49
69
  dsh plugin --profile tui add "link:$(pwd)"
50
70
  ```
51
71
 
52
- Or install the published npm package directly (requires `@deepseek-ai/dsh` and
53
- `pnpm`, no local clone needed):
54
-
55
- ```sh
56
- dsh plugin --profile tui add dsh-ssh-tui
57
- # or from a repo checkout: bash scripts/install-npm.sh
58
- ```
59
-
60
72
  For the optional **智能路由模式 (routing-suite)** mode, also run:
61
73
 
62
74
  ```sh
@@ -151,7 +163,7 @@ The plugin runs on Linux, macOS, and Windows (Node ≥ 22.19):
151
163
  | `Ctrl+R` | expand or collapse all cards |
152
164
  | `Ctrl+T` | fold the input box (display-only) |
153
165
  | `Alt+1` / `2` / `3` / `4` | jump to latest thinking / plan / subagent / reply |
154
- | `/find [kind] query` | search cards (`thinking` `plan` `subagent` `reply`). `Ctrl+/` or `Alt+/` opens it |
166
+ | `/find [kind] query` | search and jump to the full matching message (`thinking` `plan` `subagent` `reply`). `Ctrl+/` or `Alt+/` opens it |
155
167
  | `Ctrl+G` / `Alt+N` | next search hit; `Alt+P` previous |
156
168
  | `Esc` | drop selection → scroll to bottom → cancel the running turn |
157
169
  | `Ctrl+C` | cancel the running turn; exit when idle |
@@ -195,7 +207,9 @@ into the parent stream.
195
207
  The plan strip pins only the **latest incomplete plan**. When the model
196
208
  opens a new plan in the same turn, the previous one archives into the
197
209
  scrolling transcript and the dock shows the new one. A finished plan says
198
- 「计划任务已全部完成」, not 「计划模式已关闭」. The `/` menu and
210
+ 「计划任务已全部完成」, not 「计划模式已关闭」. If a turn ends with open todos, the strip says 「本轮未收尾」, stops
211
+ spinning, and sends one follow-up asking the model to `todo_write` the
212
+ real statuses. The `/` menu and
199
213
  approval/question dialogs yield that bottom space. `exit_plan_mode` is
200
214
  markdown. `ask_user_question` still opens a dialog and leaves a collapsed
201
215
  `提问用户` card. `/goal` is a collapsed `目标` card. `/find thinking foo`
@@ -310,7 +324,7 @@ switching while running.
310
324
 
311
325
  Each paint is one `stdout.write` of dirty rows only, so a jump host or
312
326
  corporate proxy does not see one SSH packet per line. Default cadence is
313
- about 120 ms. On high-RTT paths raise it:
327
+ about 160 ms, enough for most jump hosts. On a very slow path:
314
328
 
315
329
  ```sh
316
330
  export DSH_TUI_PAINT_MS=250 # 40–1000; higher = fewer packets, choppier stream
package/README.md CHANGED
@@ -53,7 +53,25 @@ SuperGrok / X Premium 订阅走配套插件 [dsh-llm-xai-oauth](https://github.c
53
53
 
54
54
  ## 部署指南
55
55
 
56
- ### 方式一:一键脚本(推荐)
56
+ 远程机器最短路径(已有 `dsh` 和 pnpm,无需 clone):
57
+
58
+ ```bash
59
+ dsh plugin --profile tui add dsh-ssh-tui
60
+ # 可选:本机已有 SuperGrok / grok-bridge token 时
61
+ dsh plugin --profile tui add github:cyjyyd/dsh-llm-xai-oauth
62
+ dsh plugin --profile headless add github:cyjyyd/dsh-llm-xai-oauth
63
+ ```
64
+
65
+ 先确认链路再开 TUI(无 TTY 时 TUI 会直接退出):
66
+
67
+ ```bash
68
+ dsh --profile headless "Reply with exactly: tui-install-ok. Do not use tools."
69
+ dsh --profile tui # 必须在真实终端 / SSH 会话里
70
+ ```
71
+
72
+ 仓库内也可:`bash scripts/smoke-headless.sh`(记录出口摘要,不打印 token)。
73
+
74
+ ### 方式一:从 git clone 安装
57
75
 
58
76
  ```bash
59
77
  git clone https://github.com/cyjyyd/dsh-ssh-tui.git
@@ -79,14 +97,10 @@ npm run build
79
97
  dsh plugin --profile tui add "link:$(pwd)"
80
98
  ```
81
99
 
82
- ### 方式三:npm 安装(发布后)
83
-
84
- 前置要求:已全局安装 `@deepseek-ai/dsh`(`npm i -g @deepseek-ai/dsh`)且有 pnpm。
100
+ ### 方式三:指定其它 profile 的 npm 安装
85
101
 
86
102
  ```bash
87
- dsh plugin --profile tui add dsh-ssh-tui
88
- # 或在仓库内快捷执行:bash scripts/install-npm.sh
89
- # 指定其它 profile:bash scripts/install-npm.sh work
103
+ bash scripts/install-npm.sh work
90
104
  ```
91
105
 
92
106
  `dsh plugin add` 会从 npm 拉取包、写入 profile 依赖,并自动把 `dsh-ssh-tui`
@@ -132,7 +146,7 @@ dsh --profile tui --no-color
132
146
  | `Ctrl+R` | 全部展开或全部收起 |
133
147
  | `Ctrl+T` | 折叠输入框(只影响显示) |
134
148
  | `Alt+1` / `2` / `3` / `4` | 跳到最新思考 / 计划 / 子代理 / 回复 |
135
- | `/find [类] 关键字` | 搜索卡片。类:`思考` `计划` `子代理` `回复`。`Ctrl+/` 或 `Alt+/` 打开 |
149
+ | `/find [类] 关键字` | 搜索并跳到该条完整消息(反色高亮)。类:`思考` `计划` `子代理` `回复`。`Ctrl+/` 或 `Alt+/` 打开 |
136
150
  | `Ctrl+G` / `Alt+N` | 下一条搜索结果;`Alt+P` 上一条 |
137
151
  | 鼠标左键 | 点击卡片标题展开/收起 |
138
152
  | `PgUp` / `PgDn`、滚轮 | 转录回看 |
@@ -171,7 +185,9 @@ SuperGrok / X Premium 走本机 OAuth,不需要填 Key。`/status` 和底栏
171
185
 
172
186
  计划条只钉**最新一条未完成的计划**。同一轮次里模型再开新计划时,旧计划归档进
173
187
  工作区随转录上滚,底栏换成新计划。任务全部完成后计划条会说「计划任务已全部完成」,
174
- 不再误报「计划模式已关闭」。打开 `/` 命令选单或批准/提问对话框时,计划条让出底栏。
188
+ 不再误报「计划模式已关闭」。一轮结束时若待办仍是进行中/待处理,计划条改成「本轮未收尾」并停止转圈,
189
+ 同时自动追问模型补一次 `todo_write`(同一列表只问一次,不改会话里的旧状态)。
190
+ 打开 `/` 命令选单或批准/提问对话框时,计划条让出底栏。
175
191
  `exit_plan_mode` 按 markdown 渲染。`ask_user_question` 仍弹对话框,并留下折叠的
176
192
  `提问用户` 卡片。`/goal` 是折叠的 `目标` 卡片。`/find 思考 padAnsi` 或 `Alt+1..4`
177
193
  可跳到对应类别的最新卡片。
@@ -274,7 +290,7 @@ npm run build
274
290
  - **标题栏或铃声不生效**:确认终端支持 OSC 0 与 BEL;铃声可用
275
291
  `DSH_TUI_NO_BELL=1` 关闭。
276
292
  - **滚轮误触取消**:已加入转义序列缓冲,网络拆包也不会把 `ESC` 当取消。
277
- - **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`,避免一行一个 SSH 包。默认约 120 ms 合一帧。穿透代理、RTT 很大时:
293
+ - **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`,避免一行一个 SSH 包。默认约 160 ms 合一帧,多数跳板机不用改。特别慢时:
278
294
  ```bash
279
295
  export DSH_TUI_PAINT_MS=250 # 40–1000;越大越省链路,流式字越顿
280
296
  ```
package/cordis.patch.yml CHANGED
@@ -81,4 +81,4 @@
81
81
  maxToolOutputLines: 6
82
82
  color: !!js ctx.sshTuiStartup.noColor !== true
83
83
  # Jump-host SSH: coalesce token ticks into one stdout write per frame.
84
- paintIntervalMs: !!js Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '120', 10) || 120
84
+ paintIntervalMs: !!js Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '160', 10) || 160
package/lib/tui.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * The renderer uses plain ANSI and coalesces each frame into one stdout
9
9
  * write of dirty rows only — jump-host / proxied SSH should see one packet
10
- * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS (default 120).
10
+ * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS (default 160).
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
13
  import { existsSync } from 'node:fs';
@@ -59,7 +59,7 @@ const PROVIDER_TEMPLATES = {
59
59
  defaultModels: ['deepseek-v4-flash'],
60
60
  },
61
61
  };
62
- const RENDER_INTERVAL_MS = 120;
62
+ const RENDER_INTERVAL_MS = 160;
63
63
  const WAIT_INDICATOR_MS = 8000;
64
64
  const MIN_PAINT_INTERVAL_MS = 40;
65
65
  const MAX_PAINT_INTERVAL_MS = 1000;
@@ -1086,6 +1086,28 @@ export function planIsLive(plan) {
1086
1086
  return true;
1087
1087
  return false;
1088
1088
  }
1089
+ /** Open todos left behind when a turn ends without a completing todo_write. */
1090
+ export function planTurnLeftOpen(plan) {
1091
+ return plan.todos.some(item => item.status !== 'completed');
1092
+ }
1093
+ /** Mark leftover in-progress/pending todos as display-stale after turn/end. */
1094
+ export function applyTurnEndToPlan(plan) {
1095
+ if (!planTurnLeftOpen(plan)) {
1096
+ plan.turnLeftOpen = false;
1097
+ return plan;
1098
+ }
1099
+ plan.turnLeftOpen = true;
1100
+ return plan;
1101
+ }
1102
+ /** Follow-up that asks the model to close leftover todos. One per open list. */
1103
+ export function planCloseNudgeText(plan) {
1104
+ const leftover = plan.todos.filter(item => item.status !== 'completed');
1105
+ const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
1106
+ return [
1107
+ '本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
1108
+ ...lines,
1109
+ ].join('\n');
1110
+ }
1089
1111
  /** Category for jump / search. Assistant replies are not collapsible cards. */
1090
1112
  export function cardCategoryOf(row) {
1091
1113
  if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
@@ -1187,6 +1209,10 @@ export function matchTranscriptRows(rows, raw) {
1187
1209
  export function planDockNote(plan) {
1188
1210
  const running = plan.todos.some(item => item.status === 'in_progress');
1189
1211
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1212
+ const leftover = plan.todos.filter(item => item.status !== 'completed').length;
1213
+ if (plan.turnLeftOpen === true && leftover > 0) {
1214
+ return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
1215
+ }
1190
1216
  if (plan.pending)
1191
1217
  return '模式切换将在下一步生效。';
1192
1218
  if (plan.active)
@@ -1816,6 +1842,8 @@ export class SshTui {
1816
1842
  searchHits = [];
1817
1843
  searchIndex = -1;
1818
1844
  searchQuery = '';
1845
+ planNudgePending = false;
1846
+ pendingReveal;
1819
1847
  constructor(ctx, agent, config) {
1820
1848
  this.ctx = ctx;
1821
1849
  this.agent = agent;
@@ -2166,6 +2194,10 @@ export class SshTui {
2166
2194
  if (existing !== undefined && planIsLive(existing)) {
2167
2195
  Object.assign(existing, patch);
2168
2196
  existing.archived = false;
2197
+ if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
2198
+ existing.turnLeftOpen = false;
2199
+ this.planNudgePending = false;
2200
+ }
2169
2201
  if (!planIsLive(existing)) {
2170
2202
  existing.archived = true;
2171
2203
  existing.expanded = false;
@@ -2196,6 +2228,27 @@ export class SshTui {
2196
2228
  shouldDockPlan() {
2197
2229
  return this.findLivePlanRow() !== undefined;
2198
2230
  }
2231
+ /** One follow-up per leftover list; replay and cancelled turns stay quiet. */
2232
+ queuePlanCloseNudge(plan) {
2233
+ if (this.replaying || this.agentGone || this.planNudgePending)
2234
+ return;
2235
+ if (this.agent.status === 'running')
2236
+ return;
2237
+ this.planNudgePending = true;
2238
+ const text = planCloseNudgeText(plan);
2239
+ this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
2240
+ const message = createUserMessage({
2241
+ content: [{ type: 'text', text }],
2242
+ source: { kind: 'user' },
2243
+ });
2244
+ try {
2245
+ this.agent.followup(message);
2246
+ }
2247
+ catch (error) {
2248
+ this.planNudgePending = false;
2249
+ this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
2250
+ }
2251
+ }
2199
2252
  /** Compact web-style plan strip pinned above the input, not in the transcript. */
2200
2253
  paintPlanDock(width, yieldBottom) {
2201
2254
  const plan = this.findLivePlanRow();
@@ -2204,8 +2257,14 @@ export class SshTui {
2204
2257
  const inner = Math.max(1, width - 2);
2205
2258
  const running = plan.todos.some(item => item.status === 'in_progress');
2206
2259
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
2207
- const spinner = (plan.active || plan.pending || running) ? ` ${this.spinnerFrame()}` : '';
2208
- const mode = plan.pending ? '切换中' : plan.active ? '计划模式' : running ? '计划' : allDone ? '计划完成' : '计划';
2260
+ const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
2261
+ const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
2262
+ const mode = plan.pending ? '切换中'
2263
+ : leftOpen ? '本轮未收尾'
2264
+ : plan.active ? '计划模式'
2265
+ : running ? '计划'
2266
+ : allDone ? '计划完成'
2267
+ : '计划';
2209
2268
  const counts = todoProgressLabel(plan.todos);
2210
2269
  const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
2211
2270
  const summary = title ?? (counts === '' ? '还没有任务' : counts);
@@ -2304,18 +2363,27 @@ export class SshTui {
2304
2363
  this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
2305
2364
  this.markDirty();
2306
2365
  }
2307
- focusCard(row) {
2366
+ highlightSearchLine(line) {
2367
+ if (line.includes('\x1b[7m'))
2368
+ return line;
2369
+ return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
2370
+ }
2371
+ revealRow(row) {
2308
2372
  if (row === undefined)
2309
2373
  return;
2310
- if (row.kind === 'assistant') {
2311
- this.focusedRow = null;
2312
- }
2313
- else if ('expanded' in row) {
2374
+ if (row.kind !== 'assistant' && 'expanded' in row) {
2375
+ row.expanded = true;
2314
2376
  this.focusedRow = row;
2315
2377
  }
2316
- this.scrollOffset = 0;
2378
+ else {
2379
+ this.focusedRow = null;
2380
+ }
2381
+ this.pendingReveal = row;
2317
2382
  this.markDirty();
2318
2383
  }
2384
+ focusCard(row) {
2385
+ this.revealRow(row);
2386
+ }
2319
2387
  /** Jump to the newest card in a category (thinking / plan / subagent / reply). */
2320
2388
  jumpToCategory(category) {
2321
2389
  if (category === 'plan') {
@@ -2323,6 +2391,7 @@ export class SshTui {
2323
2391
  if (live !== undefined) {
2324
2392
  this.focusCard(live);
2325
2393
  this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
2394
+ this.revealRow(live);
2326
2395
  return;
2327
2396
  }
2328
2397
  }
@@ -2332,10 +2401,8 @@ export class SshTui {
2332
2401
  this.markDirty();
2333
2402
  return;
2334
2403
  }
2335
- if ('expanded' in target)
2336
- target.expanded = true;
2337
- this.focusCard(target);
2338
2404
  this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
2405
+ this.revealRow(target);
2339
2406
  }
2340
2407
  applySearchHits(query, hits) {
2341
2408
  this.searchQuery = query;
@@ -2348,14 +2415,12 @@ export class SshTui {
2348
2415
  }
2349
2416
  this.searchIndex = hits.length - 1;
2350
2417
  const hit = hits[this.searchIndex];
2351
- if (hit !== undefined && 'expanded' in hit)
2352
- hit.expanded = true;
2353
- this.focusCard(hit);
2354
2418
  const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2355
2419
  this.pushRow({
2356
2420
  kind: 'system',
2357
2421
  text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
2358
2422
  });
2423
+ this.revealRow(hit);
2359
2424
  }
2360
2425
  runFindCommand(arg) {
2361
2426
  const parsed = parseFindQuery(arg);
@@ -2372,14 +2437,12 @@ export class SshTui {
2372
2437
  const count = this.searchHits.length;
2373
2438
  this.searchIndex = (this.searchIndex + delta + count) % count;
2374
2439
  const hit = this.searchHits[this.searchIndex];
2375
- if (hit !== undefined && 'expanded' in hit)
2376
- hit.expanded = true;
2377
- this.focusCard(hit);
2378
2440
  const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2379
2441
  this.pushRow({
2380
2442
  kind: 'system',
2381
2443
  text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
2382
2444
  });
2445
+ this.revealRow(hit);
2383
2446
  }
2384
2447
  paint = () => {
2385
2448
  if (this.exiting)
@@ -2388,19 +2451,21 @@ export class SshTui {
2388
2451
  const height = Math.max(6, process.stdout.rows || 24);
2389
2452
  const display = [];
2390
2453
  const displayRefs = [];
2454
+ const searchHit = this.searchHits[this.searchIndex];
2391
2455
  const addDisplay = (line, ref) => {
2392
- display.push(line);
2456
+ const hit = ref !== undefined && ref === searchHit;
2457
+ display.push(hit ? this.highlightSearchLine(line) : line);
2393
2458
  displayRefs.push(ref);
2394
2459
  };
2395
- const pushRow = (kind, text) => {
2460
+ const pushRow = (kind, text, ref) => {
2396
2461
  if (kind === 'assistant') {
2397
2462
  for (const line of renderMarkdownLines(text, width, this.color)) {
2398
- addDisplay(line);
2463
+ addDisplay(line, ref);
2399
2464
  }
2400
2465
  return;
2401
2466
  }
2402
2467
  for (const line of wrap(text, width)) {
2403
- addDisplay(this.styleLine(kind, line));
2468
+ addDisplay(this.styleLine(kind, line), ref);
2404
2469
  }
2405
2470
  };
2406
2471
  for (const row of this.rows) {
@@ -2426,7 +2491,7 @@ export class SshTui {
2426
2491
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2427
2492
  if (row.expanded) {
2428
2493
  for (const wrapped of wrap(row.text, width)) {
2429
- addDisplay(this.styleLine('reasoning', wrapped));
2494
+ addDisplay(this.styleLine('reasoning', wrapped), row);
2430
2495
  }
2431
2496
  }
2432
2497
  continue;
@@ -2474,7 +2539,7 @@ export class SshTui {
2474
2539
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
2475
2540
  for (const wrapped of wrap(line.text, inner)) {
2476
2541
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
2477
- addDisplay(this.styleLine(line.kind, body));
2542
+ addDisplay(this.styleLine(line.kind, body), row);
2478
2543
  }
2479
2544
  }
2480
2545
  continue;
@@ -2496,12 +2561,12 @@ export class SshTui {
2496
2561
  const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
2497
2562
  this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
2498
2563
  if (row.expanded) {
2499
- addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
2564
+ addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
2500
2565
  if (row.stopReason !== undefined) {
2501
- addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
2566
+ addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
2502
2567
  }
2503
2568
  if (row.logs.length === 0) {
2504
- addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
2569
+ addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
2505
2570
  }
2506
2571
  else {
2507
2572
  for (const entry of row.logs) {
@@ -2511,7 +2576,7 @@ export class SshTui {
2511
2576
  ? 'error'
2512
2577
  : 'tool-result';
2513
2578
  for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
2514
- addDisplay(this.styleLine(kind, ` ${wrapped}`));
2579
+ addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
2515
2580
  }
2516
2581
  }
2517
2582
  }
@@ -2527,16 +2592,16 @@ export class SshTui {
2527
2592
  const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
2528
2593
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
2529
2594
  if (row.expanded) {
2530
- addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`));
2595
+ addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
2531
2596
  if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
2532
2597
  for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
2533
- addDisplay(` ${line}`);
2598
+ addDisplay(` ${line}`, row);
2534
2599
  }
2535
2600
  }
2536
2601
  for (const item of row.todos) {
2537
2602
  const mark = TODO_STATUS_MARK[item.status];
2538
2603
  for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
2539
- addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`));
2604
+ addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`), row);
2540
2605
  }
2541
2606
  }
2542
2607
  }
@@ -2551,25 +2616,25 @@ export class SshTui {
2551
2616
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
2552
2617
  if (row.expanded) {
2553
2618
  if (row.header !== undefined)
2554
- addDisplay(this.styleLine('system', ` ${row.header}`));
2619
+ addDisplay(this.styleLine('system', ` ${row.header}`), row);
2555
2620
  for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
2556
- addDisplay(this.styleLine('assistant', ` ${wrapped}`));
2621
+ addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
2557
2622
  }
2558
2623
  if (row.detail !== undefined && row.detail !== '') {
2559
2624
  if (row.intent === 'plan-review') {
2560
2625
  for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
2561
- addDisplay(` ${line}`);
2626
+ addDisplay(` ${line}`, row);
2562
2627
  }
2563
2628
  }
2564
2629
  else {
2565
2630
  for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
2566
- addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
2631
+ addDisplay(this.styleLine('tool-result', ` ${wrapped}`), row);
2567
2632
  }
2568
2633
  }
2569
2634
  }
2570
2635
  addDisplay(this.styleLine('system', waiting
2571
2636
  ? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
2572
- : ` ${row.summary}`));
2637
+ : ` ${row.summary}`), row);
2573
2638
  }
2574
2639
  continue;
2575
2640
  }
@@ -2584,16 +2649,16 @@ export class SshTui {
2584
2649
  const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
2585
2650
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
2586
2651
  if (row.expanded) {
2587
- addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
2652
+ addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
2588
2653
  if (row.blockedReason !== undefined) {
2589
2654
  for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
2590
- addDisplay(this.styleLine('error', ` ${wrapped}`));
2655
+ addDisplay(this.styleLine('error', ` ${wrapped}`), row);
2591
2656
  }
2592
2657
  }
2593
2658
  }
2594
2659
  continue;
2595
2660
  }
2596
- pushRow(row.kind, row.text);
2661
+ pushRow(row.kind, row.text, row);
2597
2662
  }
2598
2663
  if (this.streaming !== undefined) {
2599
2664
  if (this.showReasoning && this.streaming.reasoning !== '') {
@@ -2611,7 +2676,7 @@ export class SshTui {
2611
2676
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
2612
2677
  if (block.expanded) {
2613
2678
  for (const wrapped of wrap(this.streaming.reasoning, width)) {
2614
- addDisplay(this.styleLine('reasoning', wrapped));
2679
+ addDisplay(this.styleLine('reasoning', wrapped), block);
2615
2680
  }
2616
2681
  }
2617
2682
  }
@@ -2805,6 +2870,18 @@ export class SshTui {
2805
2870
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
2806
2871
  const available = Math.max(1, height - reserved - dialogLines.length);
2807
2872
  const maxOffset = Math.max(0, display.length - available);
2873
+ const reveal = this.pendingReveal;
2874
+ if (reveal !== undefined) {
2875
+ this.pendingReveal = undefined;
2876
+ const first = displayRefs.findIndex(ref => ref === reveal);
2877
+ if (first !== -1) {
2878
+ let last = first;
2879
+ while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
2880
+ last += 1;
2881
+ const span = last - first + 1;
2882
+ this.scrollOffset = Math.max(0, display.length - available - first);
2883
+ }
2884
+ }
2808
2885
  if (this.scrollOffset > maxOffset)
2809
2886
  this.scrollOffset = maxOffset;
2810
2887
  const start = Math.max(0, display.length - available - this.scrollOffset);
@@ -2818,7 +2895,7 @@ export class SshTui {
2818
2895
  this.clickableRows.clear();
2819
2896
  for (let index = 0; index < visibleRefs.length; index++) {
2820
2897
  const ref = visibleRefs[index];
2821
- if (ref !== undefined)
2898
+ if (ref !== undefined && 'expanded' in ref)
2822
2899
  this.clickableRows.set(headerLines.length + index + 1, ref);
2823
2900
  }
2824
2901
  const dockPlan = this.findLivePlanRow();
@@ -2852,6 +2929,9 @@ export class SshTui {
2852
2929
  ? ' · 计划待审'
2853
2930
  : ' · 等待用户回答';
2854
2931
  }
2932
+ else if (livePlan?.turnLeftOpen === true) {
2933
+ statusText += ' · 本轮未收尾';
2934
+ }
2855
2935
  else if (livePlan?.active === true || livePlan?.pending === true) {
2856
2936
  statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
2857
2937
  }
@@ -3355,6 +3435,17 @@ export class SshTui {
3355
3435
  if (reason.kind === 'error') {
3356
3436
  this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
3357
3437
  }
3438
+ const livePlan = this.findLivePlanRow();
3439
+ if (livePlan !== undefined && reason.kind === 'completed') {
3440
+ applyTurnEndToPlan(livePlan);
3441
+ if (livePlan.turnLeftOpen === true) {
3442
+ this.pushRow({
3443
+ kind: 'system',
3444
+ text: planDockNote(livePlan),
3445
+ });
3446
+ this.queuePlanCloseNudge(livePlan);
3447
+ }
3448
+ }
3358
3449
  this.markDirty();
3359
3450
  break;
3360
3451
  }
@@ -5553,6 +5644,8 @@ export class SshTui {
5553
5644
  this.searchHits = [];
5554
5645
  this.searchIndex = -1;
5555
5646
  this.searchQuery = '';
5647
+ this.planNudgePending = false;
5648
+ this.pendingReveal = undefined;
5556
5649
  this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
5557
5650
  break;
5558
5651
  case 'status':