dsh-ssh-tui 0.3.1 → 0.3.3

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/lib/tui.js CHANGED
@@ -7,7 +7,8 @@
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, else local 80 ms
11
+ * or an SSH tier from a CSI 6n round-trip (default 160 ms).
11
12
  */
12
13
  import { spawn } from 'node:child_process';
13
14
  import { existsSync } from 'node:fs';
@@ -59,19 +60,46 @@ const PROVIDER_TEMPLATES = {
59
60
  defaultModels: ['deepseek-v4-flash'],
60
61
  },
61
62
  };
62
- const RENDER_INTERVAL_MS = 120;
63
+ const RENDER_INTERVAL_MS = 160;
64
+ const LOCAL_PAINT_INTERVAL_MS = 80;
63
65
  const WAIT_INDICATOR_MS = 8000;
64
66
  const MIN_PAINT_INTERVAL_MS = 40;
65
67
  const MAX_PAINT_INTERVAL_MS = 1000;
68
+ const DSR_PROBE_TIMEOUT_MS = 800;
66
69
  /**
67
- * Paint cadence for jump-host / proxied SSH. Token ticks coalesce into one
68
- * frame; the default stays snappy, slower links raise `DSH_TUI_PAINT_MS`.
70
+ * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
71
+ * sessions pick a tier from a measured round-trip (CSI 6n), falling back to
72
+ * 160 ms when the probe is missing.
69
73
  */
70
- export function resolvePaintIntervalMs(configured, env = process.env) {
74
+ export function resolvePaintIntervalMs(configured, env = process.env, options = {}) {
71
75
  const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
72
- if (!Number.isFinite(raw) || raw <= 0)
76
+ if (Number.isFinite(raw) && raw > 0) {
77
+ return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
78
+ }
79
+ if (options.ssh === true)
80
+ return paintIntervalForRtt(options.rttMs);
81
+ return LOCAL_PAINT_INTERVAL_MS;
82
+ }
83
+ /** True when this process is attached to an SSH session (jump host / proxy). */
84
+ export function detectSshSession(env = process.env) {
85
+ return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
86
+ }
87
+ /** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
88
+ export function paintIntervalForRtt(rttMs) {
89
+ if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
73
90
  return RENDER_INTERVAL_MS;
74
- return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
91
+ if (rttMs < 50)
92
+ return LOCAL_PAINT_INTERVAL_MS;
93
+ if (rttMs < 150)
94
+ return 160;
95
+ if (rttMs < 350)
96
+ return 250;
97
+ return 400;
98
+ }
99
+ export function paintLinkLabel(kind, intervalMs, probed) {
100
+ if (kind === 'local')
101
+ return `本机绘制 ${intervalMs}ms`;
102
+ return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
75
103
  }
76
104
  /** One incremental paint as a single stdout write (one SSH packet when corked). */
77
105
  export function composePaintOutput(options) {
@@ -994,10 +1022,58 @@ export function isEscapePrefix(text) {
994
1022
  return true;
995
1023
  if (/^\x1b\[\d+~?$/u.test(text))
996
1024
  return true;
1025
+ if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
1026
+ return true;
997
1027
  if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
998
1028
  return true;
999
1029
  return false;
1000
1030
  }
1031
+ /** Parse a Device Status Report cursor reply (`CSI row;col R`). */
1032
+ export function parseCursorPositionReply(text) {
1033
+ const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
1034
+ if (match === null)
1035
+ return undefined;
1036
+ return { row: Number(match[1]), column: Number(match[2]) };
1037
+ }
1038
+ /**
1039
+ * Round-trip to the attached terminal via CSI 6n. Returns undefined when the
1040
+ * reply never arrives (dumb pipe, blocked DSR). Does not interpret the
1041
+ * coordinates — only the elapsed milliseconds matter.
1042
+ */
1043
+ export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
1044
+ if (!stdin.isTTY || !stdout.isTTY)
1045
+ return undefined;
1046
+ return await new Promise(resolve => {
1047
+ let buffer = '';
1048
+ let settled = false;
1049
+ const started = Date.now();
1050
+ const finish = (value) => {
1051
+ if (settled)
1052
+ return;
1053
+ settled = true;
1054
+ clearTimeout(timer);
1055
+ stdin.removeListener('data', onData);
1056
+ resolve(value);
1057
+ };
1058
+ const onData = (chunk) => {
1059
+ buffer += chunk.toString('utf8');
1060
+ if (parseCursorPositionReply(buffer) !== undefined) {
1061
+ finish(Math.max(0, Date.now() - started));
1062
+ return;
1063
+ }
1064
+ if (buffer.length > 32 && !buffer.includes('\x1b['))
1065
+ finish(undefined);
1066
+ };
1067
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
1068
+ stdin.on('data', onData);
1069
+ try {
1070
+ stdout.write('\x1b[6n');
1071
+ }
1072
+ catch {
1073
+ finish(undefined);
1074
+ }
1075
+ });
1076
+ }
1001
1077
  /** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
1002
1078
  function parseJsonArgs(args) {
1003
1079
  try {
@@ -1086,6 +1162,28 @@ export function planIsLive(plan) {
1086
1162
  return true;
1087
1163
  return false;
1088
1164
  }
1165
+ /** Open todos left behind when a turn ends without a completing todo_write. */
1166
+ export function planTurnLeftOpen(plan) {
1167
+ return plan.todos.some(item => item.status !== 'completed');
1168
+ }
1169
+ /** Mark leftover in-progress/pending todos as display-stale after turn/end. */
1170
+ export function applyTurnEndToPlan(plan) {
1171
+ if (!planTurnLeftOpen(plan)) {
1172
+ plan.turnLeftOpen = false;
1173
+ return plan;
1174
+ }
1175
+ plan.turnLeftOpen = true;
1176
+ return plan;
1177
+ }
1178
+ /** Follow-up that asks the model to close leftover todos. One per open list. */
1179
+ export function planCloseNudgeText(plan) {
1180
+ const leftover = plan.todos.filter(item => item.status !== 'completed');
1181
+ const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
1182
+ return [
1183
+ '本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
1184
+ ...lines,
1185
+ ].join('\n');
1186
+ }
1089
1187
  /** Category for jump / search. Assistant replies are not collapsible cards. */
1090
1188
  export function cardCategoryOf(row) {
1091
1189
  if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
@@ -1187,6 +1285,10 @@ export function matchTranscriptRows(rows, raw) {
1187
1285
  export function planDockNote(plan) {
1188
1286
  const running = plan.todos.some(item => item.status === 'in_progress');
1189
1287
  const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1288
+ const leftover = plan.todos.filter(item => item.status !== 'completed').length;
1289
+ if (plan.turnLeftOpen === true && leftover > 0) {
1290
+ return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
1291
+ }
1190
1292
  if (plan.pending)
1191
1293
  return '模式切换将在下一步生效。';
1192
1294
  if (plan.active)
@@ -1813,9 +1915,13 @@ export class SshTui {
1813
1915
  lastPaintWidth = 0;
1814
1916
  lastPaintHeight = 0;
1815
1917
  paintIntervalMs;
1918
+ paintLink = 'local';
1919
+ paintProbed = false;
1816
1920
  searchHits = [];
1817
1921
  searchIndex = -1;
1818
1922
  searchQuery = '';
1923
+ planNudgePending = false;
1924
+ pendingReveal;
1819
1925
  constructor(ctx, agent, config) {
1820
1926
  this.ctx = ctx;
1821
1927
  this.agent = agent;
@@ -1836,7 +1942,10 @@ export class SshTui {
1836
1942
  this.presetId = config.presetId ?? 'standard';
1837
1943
  this.presetName = config.presetName ?? this.presetId;
1838
1944
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1839
- this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs);
1945
+ this.paintLink = detectSshSession() ? 'ssh' : 'local';
1946
+ this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
1947
+ ssh: this.paintLink === 'ssh',
1948
+ });
1840
1949
  this.pushRow({ kind: 'brand-logo' });
1841
1950
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1842
1951
  this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
@@ -1845,7 +1954,6 @@ export class SshTui {
1845
1954
  start() {
1846
1955
  process.stdin.setRawMode(true);
1847
1956
  process.stdin.resume();
1848
- process.stdin.on('data', this.handleData);
1849
1957
  process.stdout.on('resize', this.markDirty);
1850
1958
  process.on('SIGWINCH', this.markDirty);
1851
1959
  this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
@@ -1859,6 +1967,30 @@ export class SshTui {
1859
1967
  if (this.resumePicker) {
1860
1968
  void this.runResumeCommand('', true);
1861
1969
  }
1970
+ void this.calibratePaintInterval().finally(() => {
1971
+ if (this.disposed)
1972
+ return;
1973
+ process.stdin.on('data', this.handleData);
1974
+ this.startRenderTimer();
1975
+ });
1976
+ void this.maybeRunOnboarding().catch((error) => {
1977
+ if (this.disposed)
1978
+ return;
1979
+ this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
1980
+ this.markDirty();
1981
+ });
1982
+ void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
1983
+ if (this.disposed)
1984
+ return;
1985
+ this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
1986
+ this.markDirty();
1987
+ });
1988
+ }
1989
+ startRenderTimer() {
1990
+ if (this.renderTimer !== undefined) {
1991
+ clearInterval(this.renderTimer);
1992
+ this.renderTimer = undefined;
1993
+ }
1862
1994
  this.renderTimer = setInterval(() => {
1863
1995
  const now = Date.now();
1864
1996
  if (this.agent.status === 'running')
@@ -1871,13 +2003,9 @@ export class SshTui {
1871
2003
  if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
1872
2004
  this.dirty = true;
1873
2005
  }
1874
- // While a turn is waiting on the provider with no new events, repaint at
1875
- // most once per second so slow SSH links do not drown in redraws.
1876
2006
  const idleWaiting = this.agent.status === 'running' && !this.dirty;
1877
2007
  if (idleWaiting && now - this.lastPaintAt < 1000)
1878
2008
  return;
1879
- // Running with no fresh events only needs the seconds-bearing status
1880
- // line refreshed; force one repaint per second instead of every tick.
1881
2009
  if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
1882
2010
  this.dirty = true;
1883
2011
  }
@@ -1887,18 +2015,33 @@ export class SshTui {
1887
2015
  }
1888
2016
  }, this.paintIntervalMs);
1889
2017
  this.renderTimer.unref?.();
1890
- void this.maybeRunOnboarding().catch((error) => {
1891
- if (this.disposed)
1892
- return;
1893
- this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
1894
- this.markDirty();
1895
- });
1896
- void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
1897
- if (this.disposed)
1898
- return;
1899
- this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
1900
- this.markDirty();
2018
+ }
2019
+ async calibratePaintInterval() {
2020
+ const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2021
+ if (Number.isFinite(envOverride) && envOverride > 0) {
2022
+ this.paintProbed = false;
2023
+ this.pushRow({
2024
+ kind: 'system',
2025
+ text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
2026
+ });
2027
+ return;
2028
+ }
2029
+ if (this.paintLink !== 'ssh') {
2030
+ this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
2031
+ return;
2032
+ }
2033
+ const rtt = await probeTerminalRttMs();
2034
+ if (this.disposed)
2035
+ return;
2036
+ this.paintProbed = rtt !== undefined;
2037
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
2038
+ this.pushRow({
2039
+ kind: 'system',
2040
+ text: rtt === undefined
2041
+ ? paintLinkLabel('ssh', this.paintIntervalMs, false)
2042
+ : `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
1901
2043
  });
2044
+ this.markDirty();
1902
2045
  }
1903
2046
  /** Replay the durable session log so a resumed session renders its history. */
1904
2047
  replayHistory() {
@@ -2166,6 +2309,10 @@ export class SshTui {
2166
2309
  if (existing !== undefined && planIsLive(existing)) {
2167
2310
  Object.assign(existing, patch);
2168
2311
  existing.archived = false;
2312
+ if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
2313
+ existing.turnLeftOpen = false;
2314
+ this.planNudgePending = false;
2315
+ }
2169
2316
  if (!planIsLive(existing)) {
2170
2317
  existing.archived = true;
2171
2318
  existing.expanded = false;
@@ -2196,6 +2343,27 @@ export class SshTui {
2196
2343
  shouldDockPlan() {
2197
2344
  return this.findLivePlanRow() !== undefined;
2198
2345
  }
2346
+ /** One follow-up per leftover list; replay and cancelled turns stay quiet. */
2347
+ queuePlanCloseNudge(plan) {
2348
+ if (this.replaying || this.agentGone || this.planNudgePending)
2349
+ return;
2350
+ if (this.agent.status === 'running')
2351
+ return;
2352
+ this.planNudgePending = true;
2353
+ const text = planCloseNudgeText(plan);
2354
+ this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
2355
+ const message = createUserMessage({
2356
+ content: [{ type: 'text', text }],
2357
+ source: { kind: 'user' },
2358
+ });
2359
+ try {
2360
+ this.agent.followup(message);
2361
+ }
2362
+ catch (error) {
2363
+ this.planNudgePending = false;
2364
+ this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
2365
+ }
2366
+ }
2199
2367
  /** Compact web-style plan strip pinned above the input, not in the transcript. */
2200
2368
  paintPlanDock(width, yieldBottom) {
2201
2369
  const plan = this.findLivePlanRow();
@@ -2204,8 +2372,14 @@ export class SshTui {
2204
2372
  const inner = Math.max(1, width - 2);
2205
2373
  const running = plan.todos.some(item => item.status === 'in_progress');
2206
2374
  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 ? '计划完成' : '计划';
2375
+ const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
2376
+ const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
2377
+ const mode = plan.pending ? '切换中'
2378
+ : leftOpen ? '本轮未收尾'
2379
+ : plan.active ? '计划模式'
2380
+ : running ? '计划'
2381
+ : allDone ? '计划完成'
2382
+ : '计划';
2209
2383
  const counts = todoProgressLabel(plan.todos);
2210
2384
  const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
2211
2385
  const summary = title ?? (counts === '' ? '还没有任务' : counts);
@@ -2304,18 +2478,27 @@ export class SshTui {
2304
2478
  this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
2305
2479
  this.markDirty();
2306
2480
  }
2307
- focusCard(row) {
2481
+ highlightSearchLine(line) {
2482
+ if (line.includes('\x1b[7m'))
2483
+ return line;
2484
+ return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
2485
+ }
2486
+ revealRow(row) {
2308
2487
  if (row === undefined)
2309
2488
  return;
2310
- if (row.kind === 'assistant') {
2311
- this.focusedRow = null;
2312
- }
2313
- else if ('expanded' in row) {
2489
+ if (row.kind !== 'assistant' && 'expanded' in row) {
2490
+ row.expanded = true;
2314
2491
  this.focusedRow = row;
2315
2492
  }
2316
- this.scrollOffset = 0;
2493
+ else {
2494
+ this.focusedRow = null;
2495
+ }
2496
+ this.pendingReveal = row;
2317
2497
  this.markDirty();
2318
2498
  }
2499
+ focusCard(row) {
2500
+ this.revealRow(row);
2501
+ }
2319
2502
  /** Jump to the newest card in a category (thinking / plan / subagent / reply). */
2320
2503
  jumpToCategory(category) {
2321
2504
  if (category === 'plan') {
@@ -2323,6 +2506,7 @@ export class SshTui {
2323
2506
  if (live !== undefined) {
2324
2507
  this.focusCard(live);
2325
2508
  this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
2509
+ this.revealRow(live);
2326
2510
  return;
2327
2511
  }
2328
2512
  }
@@ -2332,10 +2516,8 @@ export class SshTui {
2332
2516
  this.markDirty();
2333
2517
  return;
2334
2518
  }
2335
- if ('expanded' in target)
2336
- target.expanded = true;
2337
- this.focusCard(target);
2338
2519
  this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
2520
+ this.revealRow(target);
2339
2521
  }
2340
2522
  applySearchHits(query, hits) {
2341
2523
  this.searchQuery = query;
@@ -2348,14 +2530,12 @@ export class SshTui {
2348
2530
  }
2349
2531
  this.searchIndex = hits.length - 1;
2350
2532
  const hit = hits[this.searchIndex];
2351
- if (hit !== undefined && 'expanded' in hit)
2352
- hit.expanded = true;
2353
- this.focusCard(hit);
2354
2533
  const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2355
2534
  this.pushRow({
2356
2535
  kind: 'system',
2357
2536
  text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
2358
2537
  });
2538
+ this.revealRow(hit);
2359
2539
  }
2360
2540
  runFindCommand(arg) {
2361
2541
  const parsed = parseFindQuery(arg);
@@ -2372,14 +2552,12 @@ export class SshTui {
2372
2552
  const count = this.searchHits.length;
2373
2553
  this.searchIndex = (this.searchIndex + delta + count) % count;
2374
2554
  const hit = this.searchHits[this.searchIndex];
2375
- if (hit !== undefined && 'expanded' in hit)
2376
- hit.expanded = true;
2377
- this.focusCard(hit);
2378
2555
  const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2379
2556
  this.pushRow({
2380
2557
  kind: 'system',
2381
2558
  text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
2382
2559
  });
2560
+ this.revealRow(hit);
2383
2561
  }
2384
2562
  paint = () => {
2385
2563
  if (this.exiting)
@@ -2388,19 +2566,21 @@ export class SshTui {
2388
2566
  const height = Math.max(6, process.stdout.rows || 24);
2389
2567
  const display = [];
2390
2568
  const displayRefs = [];
2569
+ const searchHit = this.searchHits[this.searchIndex];
2391
2570
  const addDisplay = (line, ref) => {
2392
- display.push(line);
2571
+ const hit = ref !== undefined && ref === searchHit;
2572
+ display.push(hit ? this.highlightSearchLine(line) : line);
2393
2573
  displayRefs.push(ref);
2394
2574
  };
2395
- const pushRow = (kind, text) => {
2575
+ const pushRow = (kind, text, ref) => {
2396
2576
  if (kind === 'assistant') {
2397
2577
  for (const line of renderMarkdownLines(text, width, this.color)) {
2398
- addDisplay(line);
2578
+ addDisplay(line, ref);
2399
2579
  }
2400
2580
  return;
2401
2581
  }
2402
2582
  for (const line of wrap(text, width)) {
2403
- addDisplay(this.styleLine(kind, line));
2583
+ addDisplay(this.styleLine(kind, line), ref);
2404
2584
  }
2405
2585
  };
2406
2586
  for (const row of this.rows) {
@@ -2426,7 +2606,7 @@ export class SshTui {
2426
2606
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2427
2607
  if (row.expanded) {
2428
2608
  for (const wrapped of wrap(row.text, width)) {
2429
- addDisplay(this.styleLine('reasoning', wrapped));
2609
+ addDisplay(this.styleLine('reasoning', wrapped), row);
2430
2610
  }
2431
2611
  }
2432
2612
  continue;
@@ -2474,7 +2654,7 @@ export class SshTui {
2474
2654
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
2475
2655
  for (const wrapped of wrap(line.text, inner)) {
2476
2656
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
2477
- addDisplay(this.styleLine(line.kind, body));
2657
+ addDisplay(this.styleLine(line.kind, body), row);
2478
2658
  }
2479
2659
  }
2480
2660
  continue;
@@ -2496,12 +2676,12 @@ export class SshTui {
2496
2676
  const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
2497
2677
  this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
2498
2678
  if (row.expanded) {
2499
- addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
2679
+ addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
2500
2680
  if (row.stopReason !== undefined) {
2501
- addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
2681
+ addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
2502
2682
  }
2503
2683
  if (row.logs.length === 0) {
2504
- addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
2684
+ addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
2505
2685
  }
2506
2686
  else {
2507
2687
  for (const entry of row.logs) {
@@ -2511,7 +2691,7 @@ export class SshTui {
2511
2691
  ? 'error'
2512
2692
  : 'tool-result';
2513
2693
  for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
2514
- addDisplay(this.styleLine(kind, ` ${wrapped}`));
2694
+ addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
2515
2695
  }
2516
2696
  }
2517
2697
  }
@@ -2527,16 +2707,16 @@ export class SshTui {
2527
2707
  const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
2528
2708
  this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
2529
2709
  if (row.expanded) {
2530
- addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`));
2710
+ addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
2531
2711
  if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
2532
2712
  for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
2533
- addDisplay(` ${line}`);
2713
+ addDisplay(` ${line}`, row);
2534
2714
  }
2535
2715
  }
2536
2716
  for (const item of row.todos) {
2537
2717
  const mark = TODO_STATUS_MARK[item.status];
2538
2718
  for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
2539
- addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`));
2719
+ addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`), row);
2540
2720
  }
2541
2721
  }
2542
2722
  }
@@ -2551,25 +2731,25 @@ export class SshTui {
2551
2731
  this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
2552
2732
  if (row.expanded) {
2553
2733
  if (row.header !== undefined)
2554
- addDisplay(this.styleLine('system', ` ${row.header}`));
2734
+ addDisplay(this.styleLine('system', ` ${row.header}`), row);
2555
2735
  for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
2556
- addDisplay(this.styleLine('assistant', ` ${wrapped}`));
2736
+ addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
2557
2737
  }
2558
2738
  if (row.detail !== undefined && row.detail !== '') {
2559
2739
  if (row.intent === 'plan-review') {
2560
2740
  for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
2561
- addDisplay(` ${line}`);
2741
+ addDisplay(` ${line}`, row);
2562
2742
  }
2563
2743
  }
2564
2744
  else {
2565
2745
  for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
2566
- addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
2746
+ addDisplay(this.styleLine('tool-result', ` ${wrapped}`), row);
2567
2747
  }
2568
2748
  }
2569
2749
  }
2570
2750
  addDisplay(this.styleLine('system', waiting
2571
2751
  ? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
2572
- : ` ${row.summary}`));
2752
+ : ` ${row.summary}`), row);
2573
2753
  }
2574
2754
  continue;
2575
2755
  }
@@ -2584,16 +2764,16 @@ export class SshTui {
2584
2764
  const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
2585
2765
  this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
2586
2766
  if (row.expanded) {
2587
- addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
2767
+ addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
2588
2768
  if (row.blockedReason !== undefined) {
2589
2769
  for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
2590
- addDisplay(this.styleLine('error', ` ${wrapped}`));
2770
+ addDisplay(this.styleLine('error', ` ${wrapped}`), row);
2591
2771
  }
2592
2772
  }
2593
2773
  }
2594
2774
  continue;
2595
2775
  }
2596
- pushRow(row.kind, row.text);
2776
+ pushRow(row.kind, row.text, row);
2597
2777
  }
2598
2778
  if (this.streaming !== undefined) {
2599
2779
  if (this.showReasoning && this.streaming.reasoning !== '') {
@@ -2611,7 +2791,7 @@ export class SshTui {
2611
2791
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
2612
2792
  if (block.expanded) {
2613
2793
  for (const wrapped of wrap(this.streaming.reasoning, width)) {
2614
- addDisplay(this.styleLine('reasoning', wrapped));
2794
+ addDisplay(this.styleLine('reasoning', wrapped), block);
2615
2795
  }
2616
2796
  }
2617
2797
  }
@@ -2805,6 +2985,18 @@ export class SshTui {
2805
2985
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
2806
2986
  const available = Math.max(1, height - reserved - dialogLines.length);
2807
2987
  const maxOffset = Math.max(0, display.length - available);
2988
+ const reveal = this.pendingReveal;
2989
+ if (reveal !== undefined) {
2990
+ this.pendingReveal = undefined;
2991
+ const first = displayRefs.findIndex(ref => ref === reveal);
2992
+ if (first !== -1) {
2993
+ let last = first;
2994
+ while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
2995
+ last += 1;
2996
+ const span = last - first + 1;
2997
+ this.scrollOffset = Math.max(0, display.length - available - first);
2998
+ }
2999
+ }
2808
3000
  if (this.scrollOffset > maxOffset)
2809
3001
  this.scrollOffset = maxOffset;
2810
3002
  const start = Math.max(0, display.length - available - this.scrollOffset);
@@ -2818,7 +3010,7 @@ export class SshTui {
2818
3010
  this.clickableRows.clear();
2819
3011
  for (let index = 0; index < visibleRefs.length; index++) {
2820
3012
  const ref = visibleRefs[index];
2821
- if (ref !== undefined)
3013
+ if (ref !== undefined && 'expanded' in ref)
2822
3014
  this.clickableRows.set(headerLines.length + index + 1, ref);
2823
3015
  }
2824
3016
  const dockPlan = this.findLivePlanRow();
@@ -2844,6 +3036,7 @@ export class SshTui {
2844
3036
  statusText += ' · Ctrl+T 折叠输入';
2845
3037
  if (this.pendingMessages.size > 0)
2846
3038
  statusText += ` · 排队 ${this.pendingMessages.size}`;
3039
+ statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
2847
3040
  const idleMs = Date.now() - this.lastActivity;
2848
3041
  const livePlan = this.findLivePlanRow();
2849
3042
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
@@ -2852,6 +3045,9 @@ export class SshTui {
2852
3045
  ? ' · 计划待审'
2853
3046
  : ' · 等待用户回答';
2854
3047
  }
3048
+ else if (livePlan?.turnLeftOpen === true) {
3049
+ statusText += ' · 本轮未收尾';
3050
+ }
2855
3051
  else if (livePlan?.active === true || livePlan?.pending === true) {
2856
3052
  statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
2857
3053
  }
@@ -3355,6 +3551,17 @@ export class SshTui {
3355
3551
  if (reason.kind === 'error') {
3356
3552
  this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
3357
3553
  }
3554
+ const livePlan = this.findLivePlanRow();
3555
+ if (livePlan !== undefined && reason.kind === 'completed') {
3556
+ applyTurnEndToPlan(livePlan);
3557
+ if (livePlan.turnLeftOpen === true) {
3558
+ this.pushRow({
3559
+ kind: 'system',
3560
+ text: planDockNote(livePlan),
3561
+ });
3562
+ this.queuePlanCloseNudge(livePlan);
3563
+ }
3564
+ }
3358
3565
  this.markDirty();
3359
3566
  break;
3360
3567
  }
@@ -4641,6 +4848,8 @@ export class SshTui {
4641
4848
  this.markDirty();
4642
4849
  return;
4643
4850
  }
4851
+ if (parseCursorPositionReply(combined) !== undefined)
4852
+ return;
4644
4853
  if (combined === '\x1b[H' || combined === '\x1b[1~') {
4645
4854
  this.cursor = 0;
4646
4855
  this.markDirty();
@@ -5553,6 +5762,8 @@ export class SshTui {
5553
5762
  this.searchHits = [];
5554
5763
  this.searchIndex = -1;
5555
5764
  this.searchQuery = '';
5765
+ this.planNudgePending = false;
5766
+ this.pendingReveal = undefined;
5556
5767
  this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
5557
5768
  break;
5558
5769
  case 'status':
@@ -5571,6 +5782,7 @@ export class SshTui {
5571
5782
  `preset: ${this.presetName}`,
5572
5783
  `subagents: ${this.activeSubagents.size}`,
5573
5784
  `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
5785
+ `paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
5574
5786
  waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
5575
5787
  ];
5576
5788
  this.pushRow({ kind: 'system', text: lines.join('\n') });