dsh-ssh-tui 0.3.0 → 0.3.1

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
@@ -5,9 +5,9 @@
5
5
  * `ask_user_question` prompts from the keyboard, and drives one configured
6
6
  * agent with followup/steer.
7
7
  *
8
- * The renderer uses plain ANSI and a throttled full repaint, which keeps it
9
- * predictable over slow SSH links and avoids terminal-library dependency
10
- * drift inside the plugin.
8
+ * The renderer uses plain ANSI and coalesces each frame into one stdout
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).
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
13
  import { existsSync } from 'node:fs';
@@ -21,7 +21,7 @@ import { SessionId } from '@deepseek-ai/dsh-session';
21
21
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
22
22
  import { formatSessionTime, listResumableSessions } from './session-list.js';
23
23
  import { defaultReasoningEffort } from './reasoning.js';
24
- import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, subagentSettingsValue, } from './subagent-model.js';
24
+ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
25
25
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
26
26
  const PROVIDER_TEMPLATES = {
27
27
  official: {
@@ -61,6 +61,46 @@ const PROVIDER_TEMPLATES = {
61
61
  };
62
62
  const RENDER_INTERVAL_MS = 120;
63
63
  const WAIT_INDICATOR_MS = 8000;
64
+ const MIN_PAINT_INTERVAL_MS = 40;
65
+ const MAX_PAINT_INTERVAL_MS = 1000;
66
+ /**
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`.
69
+ */
70
+ export function resolvePaintIntervalMs(configured, env = process.env) {
71
+ const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
72
+ if (!Number.isFinite(raw) || raw <= 0)
73
+ return RENDER_INTERVAL_MS;
74
+ return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
75
+ }
76
+ /** One incremental paint as a single stdout write (one SSH packet when corked). */
77
+ export function composePaintOutput(options) {
78
+ const { width, height, paintRows, previousRows, sizeChanged, chromeChanged, chromeStart } = options;
79
+ let out = '\x1b[?25l';
80
+ const prev = sizeChanged ? [] : previousRows;
81
+ if (sizeChanged)
82
+ out += '\x1b[H\x1b[J';
83
+ // Never address row height+1: that scrolls the SSH viewport and leaves
84
+ // thinking/tool/assistant glyphs sitting on the next card.
85
+ const rowCount = Math.min(height, paintRows.length);
86
+ for (let i = 0; i < rowCount; i++) {
87
+ const current = paintRows[i] ?? '';
88
+ if (current === prev[i] && !(chromeChanged && i >= chromeStart))
89
+ continue;
90
+ const clipped = padAnsiToWidth(current, width);
91
+ // EL2 *before* the glyphs, from column 1. A full-width write followed
92
+ // by EL hits DEC auto-margin: the cursor wraps, and EL then blanks the
93
+ // next card instead of the row we just drew.
94
+ out += `\x1b[${i + 1};1H\x1b[0m\x1b[2K${clipped}\x1b[0m`;
95
+ }
96
+ if (rowCount < height) {
97
+ out += `\x1b[${rowCount + 1};1H\x1b[J`;
98
+ }
99
+ out += '\x1b[0m';
100
+ const cursorRow = Math.min(height, Math.max(1, options.cursorRow));
101
+ out += `\x1b[${cursorRow};${Math.max(1, options.cursorColumn)}H\x1b[?25h`;
102
+ return out;
103
+ }
64
104
  const STALL_WARNING_MS = 60000;
65
105
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
66
106
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
@@ -228,10 +268,23 @@ const LOCAL_COMMANDS = [
228
268
  { name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
229
269
  { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
230
270
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
231
- { name: 'setup', description: 're-open provider / API key setup' },
271
+ { name: 'setup', description: 'configure an API-key provider (DeepSeek / OpenCode); SuperGrok uses local OAuth' },
272
+ { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
232
273
  { name: 'dialog-test', description: 'verify the question dialog' },
233
274
  ];
234
- function displayWidth(text) {
275
+ /**
276
+ * Terminal cell width for one string.
277
+ *
278
+ * Match glibc wcwidth / typical UTF-8 SSH terminals: CJK ideographs and
279
+ * fullwidth forms occupy two cells; East-Asian Ambiguous box-drawing and
280
+ * ornaments (`─`, `●`, `·`, `▸`, `❯`, Braille spinners) occupy one. Counting
281
+ * those ambiguous glyphs as two made `repeatToWidth('─', cols)` paint a
282
+ * half-width rule and parked the input cursor half a cell past the text.
283
+ *
284
+ * Overflow into the input box is handled by clipping/padding painted rows to
285
+ * the measured column count, not by inflating glyph width.
286
+ */
287
+ export function displayWidth(text) {
235
288
  let width = 0;
236
289
  for (const char of text) {
237
290
  if (char === '\t') {
@@ -241,11 +294,19 @@ function displayWidth(text) {
241
294
  continue;
242
295
  }
243
296
  const cp = char.codePointAt(0) ?? 0;
297
+ if (cp === 0x00ad || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0x2060 && cp <= 0x2064) || cp === 0xfeff) {
298
+ continue;
299
+ }
300
+ if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) {
301
+ continue;
302
+ }
244
303
  const wide = (cp >= 0x1100 && cp <= 0x115f) ||
304
+ cp === 0x2329 || cp === 0x232a ||
245
305
  (cp >= 0x2e80 && cp <= 0xa4cf) ||
246
306
  (cp >= 0xac00 && cp <= 0xd7a3) ||
247
307
  (cp >= 0xf900 && cp <= 0xfaff) ||
248
- (cp >= 0xfe30 && cp <= 0xfe4f) ||
308
+ (cp >= 0xfe10 && cp <= 0xfe19) ||
309
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
249
310
  (cp >= 0xff00 && cp <= 0xff60) ||
250
311
  (cp >= 0xffe0 && cp <= 0xffe6) ||
251
312
  (cp >= 0x1f300 && cp <= 0x1faff) ||
@@ -254,6 +315,100 @@ function displayWidth(text) {
254
315
  }
255
316
  return width;
256
317
  }
318
+ /** Pad or clip one already-sanitized line so it occupies exactly `width` cells. */
319
+ export function padToWidth(text, width) {
320
+ const safe = sanitizeTerminalText(text);
321
+ if (width <= 0)
322
+ return '';
323
+ const clipped = truncateToWidth(safe, width);
324
+ const used = displayWidth(clipped);
325
+ return used >= width ? clipped : `${clipped}${' '.repeat(width - used)}`;
326
+ }
327
+ /**
328
+ * Pad an already-styled ANSI line to `width` cells without resetting SGR.
329
+ * Diff add/del rows keep their background across the whole terminal row
330
+ * instead of only the glyphs.
331
+ */
332
+ export function padAnsiToWidth(text, width) {
333
+ if (width <= 0)
334
+ return '';
335
+ const clipped = clipAnsiToWidth(text, width);
336
+ const used = visibleWidth(clipped);
337
+ if (used >= width)
338
+ return clipped;
339
+ const pad = ' '.repeat(width - used);
340
+ // Insert spaces before a trailing SGR reset so backgrounds (diff rows)
341
+ // and the cell budget both fill the whole terminal row.
342
+ if (clipped.endsWith('\x1b[0m'))
343
+ return `${clipped.slice(0, -4)}${pad}\x1b[0m`;
344
+ return `${clipped}${pad}`;
345
+ }
346
+ /** Visible width of an ANSI-styled line, ignoring CSI / OSC sequences. */
347
+ export function visibleWidth(text) {
348
+ let used = 0;
349
+ let index = 0;
350
+ while (index < text.length) {
351
+ if (text.charCodeAt(index) === 0x1b) {
352
+ index = skipAnsiSequence(text, index);
353
+ continue;
354
+ }
355
+ const cp = text.codePointAt(index);
356
+ if (cp === undefined)
357
+ break;
358
+ const char = String.fromCodePoint(cp);
359
+ used += displayWidth(char);
360
+ index += char.length;
361
+ }
362
+ return used;
363
+ }
364
+ /** Advance past one ESC sequence starting at `index`. */
365
+ function skipAnsiSequence(text, index) {
366
+ let seqEnd = index + 1;
367
+ if (seqEnd >= text.length)
368
+ return text.length;
369
+ const intro = text.charCodeAt(seqEnd);
370
+ if (intro === 0x5b) {
371
+ seqEnd += 1;
372
+ while (seqEnd < text.length) {
373
+ const code = text.charCodeAt(seqEnd);
374
+ seqEnd += 1;
375
+ if (code >= 0x40 && code <= 0x7e)
376
+ break;
377
+ }
378
+ return seqEnd;
379
+ }
380
+ if (intro === 0x5d) {
381
+ seqEnd += 1;
382
+ while (seqEnd < text.length) {
383
+ const code = text.charCodeAt(seqEnd);
384
+ seqEnd += 1;
385
+ if (code === 0x07)
386
+ break;
387
+ if (code === 0x1b && text.charCodeAt(seqEnd) === 0x5c) {
388
+ seqEnd += 1;
389
+ break;
390
+ }
391
+ }
392
+ return seqEnd;
393
+ }
394
+ while (seqEnd < text.length) {
395
+ const code = text.charCodeAt(seqEnd);
396
+ seqEnd += 1;
397
+ if (code >= 0x40 && code <= 0x7e)
398
+ break;
399
+ }
400
+ return seqEnd;
401
+ }
402
+ /** Repeat a glyph until it occupies exactly `width` cells. */
403
+ export function repeatToWidth(glyph, width) {
404
+ if (width <= 0)
405
+ return '';
406
+ const unit = displayWidth(glyph);
407
+ if (unit <= 0)
408
+ return ' '.repeat(width);
409
+ const count = Math.max(1, Math.floor(width / unit));
410
+ return padToWidth(glyph.repeat(count), width);
411
+ }
257
412
  /** Strip terminal control sequences and expand tabs for display output. */
258
413
  function sanitizeTerminalText(text) {
259
414
  return text
@@ -266,6 +421,7 @@ function firstCodePointLength(text) {
266
421
  return Array.from(text)[0]?.length ?? 1;
267
422
  }
268
423
  function wrap(text, width) {
424
+ const limit = Math.max(1, width);
269
425
  const lines = [];
270
426
  for (const sourceLine of text.split('\n')) {
271
427
  if (sourceLine === '') {
@@ -273,18 +429,21 @@ function wrap(text, width) {
273
429
  continue;
274
430
  }
275
431
  let rest = sanitizeTerminalText(sourceLine);
276
- while (displayWidth(rest) > width) {
432
+ while (displayWidth(rest) > limit) {
277
433
  let cut = 0;
278
434
  let used = 0;
279
435
  for (const char of rest) {
280
436
  const charWidth = displayWidth(char);
281
- if (used + charWidth > width)
437
+ if (charWidth > 0 && used + charWidth > limit)
282
438
  break;
283
439
  used += charWidth;
284
440
  cut += char.length;
285
441
  }
286
- if (cut === 0)
442
+ if (cut === 0) {
443
+ // A single double-width glyph on a 1-cell row still has to occupy a
444
+ // line; the next wrap continues after it so we never stall.
287
445
  cut = firstCodePointLength(rest);
446
+ }
288
447
  lines.push(rest.slice(0, cut));
289
448
  rest = rest.slice(cut);
290
449
  }
@@ -364,8 +523,14 @@ function wrapMarkdownSegments(segments, width, prefixSegments = []) {
364
523
  const slice = forwardSliceByWidth(rest, available);
365
524
  let chunk = slice.text;
366
525
  if (chunk === '') {
367
- // A wide character does not fit the remaining cell; take one code
368
- // point so the loop always makes progress. The terminal wraps it.
526
+ // A wide character does not fit the remaining cell: wrap to the next
527
+ // row instead of overflowing that cell into the input area.
528
+ if (used > 0) {
529
+ lines.push(current);
530
+ current = [];
531
+ used = 0;
532
+ continue;
533
+ }
369
534
  chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
370
535
  }
371
536
  current.push({ kind: segment.kind, text: chunk });
@@ -503,7 +668,7 @@ export function renderMarkdownLines(text, width, color) {
503
668
  if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
504
669
  lines.push(renderMarkdownBlockLine({
505
670
  base: 'rule',
506
- segments: [{ kind: 'text', text: '─'.repeat(Math.max(1, width)) }],
671
+ segments: [{ kind: 'text', text: repeatToWidth('─', Math.max(1, width)) }],
507
672
  }, color));
508
673
  continue;
509
674
  }
@@ -559,6 +724,37 @@ export function truncateToWidth(text, width) {
559
724
  cut = firstCodePointLength(safe);
560
725
  return `${safe.slice(0, cut)}…`;
561
726
  }
727
+ /**
728
+ * Clip an already-styled ANSI line to `width` terminal cells without dropping
729
+ * the reset/SGR sequences. Used by the incremental painter so a leftover wide
730
+ * glyph cannot wrap into the next row.
731
+ */
732
+ export function clipAnsiToWidth(text, width) {
733
+ if (width <= 0)
734
+ return '';
735
+ let used = 0;
736
+ let out = '';
737
+ let index = 0;
738
+ while (index < text.length) {
739
+ if (text.charCodeAt(index) === 0x1b) {
740
+ const seqEnd = skipAnsiSequence(text, index);
741
+ out += text.slice(index, seqEnd);
742
+ index = seqEnd;
743
+ continue;
744
+ }
745
+ const cp = text.codePointAt(index);
746
+ if (cp === undefined)
747
+ break;
748
+ const char = String.fromCodePoint(cp);
749
+ const charWidth = displayWidth(char);
750
+ if (used + charWidth > width)
751
+ break;
752
+ out += char;
753
+ used += charWidth;
754
+ index += char.length;
755
+ }
756
+ return out;
757
+ }
562
758
  /** Slice up to `maxWidth` display columns from the beginning of `text`. */
563
759
  function forwardSliceByWidth(text, maxWidth) {
564
760
  let cut = 0;
@@ -877,9 +1073,167 @@ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
877
1073
  const MAX_SUBAGENT_LOGS = 80;
878
1074
  const TODO_STATUS_MARK = {
879
1075
  pending: '○',
880
- in_progress: '',
881
- completed: '',
1076
+ in_progress: '',
1077
+ completed: '',
882
1078
  };
1079
+ /** True while a plan still belongs in the dock (latest incomplete work). */
1080
+ export function planIsLive(plan) {
1081
+ if (plan.archived === true)
1082
+ return false;
1083
+ if (plan.active || plan.pending)
1084
+ return true;
1085
+ if (plan.todos.some(item => item.status !== 'completed'))
1086
+ return true;
1087
+ return false;
1088
+ }
1089
+ /** Category for jump / search. Assistant replies are not collapsible cards. */
1090
+ export function cardCategoryOf(row) {
1091
+ if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
1092
+ return 'thinking';
1093
+ if (row.kind === 'plan')
1094
+ return 'plan';
1095
+ if (row.kind === 'subagent')
1096
+ return 'subagent';
1097
+ if (row.kind === 'assistant')
1098
+ return 'reply';
1099
+ if (row.kind === 'tool')
1100
+ return 'tool';
1101
+ if (row.kind === 'question')
1102
+ return 'question';
1103
+ if (row.kind === 'goal')
1104
+ return 'goal';
1105
+ return undefined;
1106
+ }
1107
+ const CARD_CATEGORY_LABEL = {
1108
+ thinking: '思考',
1109
+ plan: '计划',
1110
+ subagent: '子代理',
1111
+ reply: '回复',
1112
+ tool: '工具',
1113
+ question: '提问',
1114
+ goal: '目标',
1115
+ };
1116
+ const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
1117
+ function parseCardCategoryToken(token) {
1118
+ const id = token.trim().toLowerCase();
1119
+ if (id === 'thinking' || id === 'think' || id === '推理' || id === '思考')
1120
+ return 'thinking';
1121
+ if (id === 'plan' || id === '计划')
1122
+ return 'plan';
1123
+ if (id === 'subagent' || id === 'sub' || id === '子代理')
1124
+ return 'subagent';
1125
+ if (id === 'reply' || id === 'assistant' || id === '回复')
1126
+ return 'reply';
1127
+ if (id === 'tool' || id === '工具')
1128
+ return 'tool';
1129
+ if (id === 'question' || id === '提问')
1130
+ return 'question';
1131
+ if (id === 'goal' || id === '目标')
1132
+ return 'goal';
1133
+ return undefined;
1134
+ }
1135
+ /** Split `/find thinking padAnsi` into an optional category and a query. */
1136
+ export function parseFindQuery(raw) {
1137
+ const text = raw.trim();
1138
+ if (text === '')
1139
+ return { query: '' };
1140
+ const match = /^(\S+)(?:\s+(.*))?$/u.exec(text);
1141
+ if (match === null)
1142
+ return { query: text };
1143
+ const category = parseCardCategoryToken(match[1] ?? '');
1144
+ if (category === undefined)
1145
+ return { query: text };
1146
+ return { category, query: (match[2] ?? '').trim() };
1147
+ }
1148
+ function rowSearchHaystack(row) {
1149
+ switch (row.kind) {
1150
+ case 'reasoning':
1151
+ case 'assistant':
1152
+ case 'user':
1153
+ case 'system':
1154
+ case 'error':
1155
+ case 'brand':
1156
+ return row.text;
1157
+ case 'tool':
1158
+ return `${row.title} ${row.summary} ${row.output} ${row.args}`;
1159
+ case 'subagent':
1160
+ return `${row.label} ${row.lastActivity} ${row.logs.map(entry => entry.text).join('\n')}`;
1161
+ case 'plan':
1162
+ return `${row.planMarkdown ?? ''} ${row.todos.map(item => item.content).join('\n')}`;
1163
+ case 'question':
1164
+ return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
1165
+ case 'goal':
1166
+ return `${row.objective} ${row.blockedReason ?? ''}`;
1167
+ default:
1168
+ return '';
1169
+ }
1170
+ }
1171
+ /** Transcript rows matching a `/find` query, newest last. */
1172
+ export function matchTranscriptRows(rows, raw) {
1173
+ const { category, query } = parseFindQuery(raw);
1174
+ const needle = query.toLowerCase();
1175
+ return rows.filter(row => {
1176
+ const kind = cardCategoryOf(row);
1177
+ if (kind === undefined)
1178
+ return false;
1179
+ if (category !== undefined && kind !== category)
1180
+ return false;
1181
+ if (needle === '')
1182
+ return SEARCHABLE_CATEGORIES.includes(kind) || category !== undefined;
1183
+ return rowSearchHaystack(row).toLowerCase().includes(needle);
1184
+ });
1185
+ }
1186
+ /** One-line note under an expanded plan strip. */
1187
+ export function planDockNote(plan) {
1188
+ const running = plan.todos.some(item => item.status === 'in_progress');
1189
+ const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
1190
+ if (plan.pending)
1191
+ return '模式切换将在下一步生效。';
1192
+ if (plan.active)
1193
+ return '只规划、不改代码;确认后再执行。';
1194
+ if (running)
1195
+ return '正在按计划执行。';
1196
+ if (allDone)
1197
+ return '计划任务已全部完成。';
1198
+ if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
1199
+ return '计划还在,尚未全部完成。';
1200
+ }
1201
+ return '计划模式已关闭,可用 /plan 重新进入。';
1202
+ }
1203
+ /** Compact per-status counts matching the web plan strip. */
1204
+ export function todoProgressLabel(todos) {
1205
+ const done = todos.filter(item => item.status === 'completed').length;
1206
+ const active = todos.filter(item => item.status === 'in_progress').length;
1207
+ const pending = todos.length - done - active;
1208
+ const parts = [];
1209
+ if (done > 0)
1210
+ parts.push(`${done} 已完成`);
1211
+ if (active > 0)
1212
+ parts.push(`${active} 进行中`);
1213
+ if (pending > 0)
1214
+ parts.push(`${pending} 待处理`);
1215
+ return parts.join(' · ');
1216
+ }
1217
+ function todoItemKind(status) {
1218
+ if (status === 'completed')
1219
+ return 'todo-done';
1220
+ if (status === 'in_progress')
1221
+ return 'todo-active';
1222
+ return 'todo-pending';
1223
+ }
1224
+ function planMarkdownFromArgs(value) {
1225
+ const root = typeof value === 'string' ? parseJsonArgs(value) : value;
1226
+ if (root === null || typeof root !== 'object' || Array.isArray(root))
1227
+ return undefined;
1228
+ const plan = root.plan;
1229
+ return typeof plan === 'string' && plan.trim() !== '' ? plan : undefined;
1230
+ }
1231
+ /** First markdown heading of an exit_plan_mode plan body. */
1232
+ export function planTitleFromMarkdown(markdown) {
1233
+ const match = /^\s*#\s+(.+)$/mu.exec(markdown);
1234
+ const title = match?.[1]?.trim();
1235
+ return title === undefined || title === '' ? undefined : title;
1236
+ }
883
1237
  /** Parse a todo_write payload into displayable plan items. */
884
1238
  export function parsePlanTodos(value) {
885
1239
  const root = typeof value === 'string' ? parseJsonArgs(value) : value;
@@ -1025,13 +1379,40 @@ export function presentToolCall(name, args) {
1025
1379
  };
1026
1380
  }
1027
1381
  if (name === 'todo_write' || name === 'todo') {
1028
- return { title: '计划', summary: todoSummary(parsed) };
1382
+ return { title: '更新待办', summary: todoSummary(parsed) };
1029
1383
  }
1030
1384
  if (name === 'ask_user_question') {
1031
1385
  return { title: '提问用户', summary: askSummary(parsed) };
1032
1386
  }
1033
1387
  if (name === 'exit_plan_mode') {
1034
- return { title: '退出计划模式', summary: '等待确认计划' };
1388
+ const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
1389
+ return { title: '提交计划', summary: planTitleFromMarkdown(plan) ?? '等待确认计划' };
1390
+ }
1391
+ if (name === 'read') {
1392
+ const path = typeof parsed?.path === 'string' ? parsed.path
1393
+ : typeof parsed?.file_path === 'string' ? parsed.file_path
1394
+ : typeof parsed?.url === 'string' ? parsed.url
1395
+ : '';
1396
+ return { title: '读取', summary: path || friendlyArgsSummary(name, args) };
1397
+ }
1398
+ if (name === 'grep') {
1399
+ const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
1400
+ const path = typeof parsed?.path === 'string' ? parsed.path : '';
1401
+ return { title: '搜索', summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
1402
+ }
1403
+ if (name === 'glob') {
1404
+ const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
1405
+ : typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
1406
+ : '';
1407
+ return { title: '匹配文件', summary: pattern || friendlyArgsSummary(name, args) };
1408
+ }
1409
+ if (name === 'web_search') {
1410
+ const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
1411
+ return { title: '网页搜索', summary: query || friendlyArgsSummary(name, args) };
1412
+ }
1413
+ if (name === 'web_fetch') {
1414
+ const url = typeof parsed?.url === 'string' ? parsed.url : '';
1415
+ return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
1035
1416
  }
1036
1417
  return { title: name, summary: friendlyArgsSummary(name, args) };
1037
1418
  }
@@ -1212,6 +1593,9 @@ export function toolBodyLines(row, maxLines) {
1212
1593
  }
1213
1594
  return out;
1214
1595
  }
1596
+ const specialized = specializedToolBody(row);
1597
+ if (specialized !== null)
1598
+ return capDisplayLines(specialized, maxLines);
1215
1599
  const out = [];
1216
1600
  const args = parseJsonArgs(row.args);
1217
1601
  if (args !== null && Object.keys(args).length > 0) {
@@ -1236,6 +1620,86 @@ export function toolBodyLines(row, maxLines) {
1236
1620
  }
1237
1621
  return capDisplayLines(out, maxLines);
1238
1622
  }
1623
+ function firstString(record, keys) {
1624
+ for (const key of keys) {
1625
+ const value = record[key];
1626
+ if (typeof value === 'string' && value.trim() !== '')
1627
+ return value;
1628
+ }
1629
+ return '';
1630
+ }
1631
+ function specializedToolBody(row) {
1632
+ const name = row.name ?? '';
1633
+ const args = parseJsonArgs(row.args);
1634
+ if (name === 'todo_write' || name === 'todo') {
1635
+ const todos = parsePlanTodos(args ?? row.args);
1636
+ const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
1637
+ if (todos.length === 0) {
1638
+ out.push({ kind: 'tool-result', text: '还没有任务' });
1639
+ }
1640
+ else {
1641
+ for (const item of todos) {
1642
+ out.push({ kind: todoItemKind(item.status), text: `${TODO_STATUS_MARK[item.status]} ${item.content}` });
1643
+ }
1644
+ }
1645
+ return out;
1646
+ }
1647
+ if (name === 'exit_plan_mode') {
1648
+ const markdown = planMarkdownFromArgs(args ?? row.args) ?? '';
1649
+ const out = [{ kind: 'diff-path', text: planTitleFromMarkdown(markdown) ?? '待审计划' }];
1650
+ if (markdown === '') {
1651
+ out.push({ kind: 'tool-result', text: '计划正文为空' });
1652
+ }
1653
+ else {
1654
+ for (const line of markdown.split('\n')) {
1655
+ out.push({ kind: 'assistant', text: line });
1656
+ }
1657
+ }
1658
+ return out;
1659
+ }
1660
+ if (name === 'read' && args !== null) {
1661
+ const path = firstString(args, ['path', 'file_path', 'url']);
1662
+ const out = [];
1663
+ if (path !== '')
1664
+ out.push({ kind: 'diff-path', text: path });
1665
+ const offset = typeof args.offset === 'number' ? args.offset : undefined;
1666
+ const limit = typeof args.limit === 'number' ? args.limit : undefined;
1667
+ if (offset !== undefined || limit !== undefined) {
1668
+ out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
1669
+ }
1670
+ if (row.output !== '') {
1671
+ for (const line of truncate(row.output, 40).split('\n')) {
1672
+ out.push({ kind: 'tool-result', text: line });
1673
+ }
1674
+ }
1675
+ else if (row.status === 'running') {
1676
+ out.push({ kind: 'tool-result', text: '读取中…' });
1677
+ }
1678
+ return out.length > 0 ? out : null;
1679
+ }
1680
+ if ((name === 'grep' || name === 'glob') && args !== null) {
1681
+ const pattern = firstString(args, ['pattern', 'glob_pattern', 'query']);
1682
+ const path = firstString(args, ['path', 'glob']);
1683
+ const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
1684
+ if (row.output !== '') {
1685
+ for (const line of truncate(row.output, 30).split('\n')) {
1686
+ out.push({ kind: 'tool-result', text: line });
1687
+ }
1688
+ }
1689
+ return out;
1690
+ }
1691
+ if ((name === 'web_search' || name === 'web_fetch') && args !== null) {
1692
+ const query = firstString(args, ['query', 'q', 'url']);
1693
+ const out = [{ kind: 'diff-path', text: query || name }];
1694
+ if (row.output !== '') {
1695
+ for (const line of truncate(row.output, 24).split('\n')) {
1696
+ out.push({ kind: 'assistant', text: line });
1697
+ }
1698
+ }
1699
+ return out;
1700
+ }
1701
+ return null;
1702
+ }
1239
1703
  /** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
1240
1704
  export function parseExitStatus(text) {
1241
1705
  const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
@@ -1346,6 +1810,12 @@ export class SshTui {
1346
1810
  lastTitleUpdateAt = 0;
1347
1811
  lastPaintRows = [];
1348
1812
  lastChromeKey = '';
1813
+ lastPaintWidth = 0;
1814
+ lastPaintHeight = 0;
1815
+ paintIntervalMs;
1816
+ searchHits = [];
1817
+ searchIndex = -1;
1818
+ searchQuery = '';
1349
1819
  constructor(ctx, agent, config) {
1350
1820
  this.ctx = ctx;
1351
1821
  this.agent = agent;
@@ -1366,9 +1836,10 @@ export class SshTui {
1366
1836
  this.presetId = config.presetId ?? 'standard';
1367
1837
  this.presetName = config.presetName ?? this.presetId;
1368
1838
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1839
+ this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs);
1369
1840
  this.pushRow({ kind: 'brand-logo' });
1370
1841
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1371
- this.pushRow({ kind: 'system', text: '输入 /help 查看命令 · /setup 配置提供商 · ↑/↓ 选择卡片 · Enter 展开/折叠 · Ctrl+R 全部展开/收起 · Ctrl+T 折叠输入 · Esc 取消' });
1842
+ this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
1372
1843
  }
1373
1844
  /** Enter raw mode, switch to the alternate screen, and start listening. */
1374
1845
  start() {
@@ -1397,7 +1868,7 @@ export class SshTui {
1397
1868
  || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
1398
1869
  || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
1399
1870
  || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
1400
- if (animating && now - this.lastPaintAt >= 200) {
1871
+ if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
1401
1872
  this.dirty = true;
1402
1873
  }
1403
1874
  // While a turn is waiting on the provider with no new events, repaint at
@@ -1414,7 +1885,7 @@ export class SshTui {
1414
1885
  this.lastPaintAt = now;
1415
1886
  this.render();
1416
1887
  }
1417
- }, RENDER_INTERVAL_MS);
1888
+ }, this.paintIntervalMs);
1418
1889
  this.renderTimer.unref?.();
1419
1890
  void this.maybeRunOnboarding().catch((error) => {
1420
1891
  if (this.disposed)
@@ -1422,6 +1893,12 @@ export class SshTui {
1422
1893
  this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
1423
1894
  this.markDirty();
1424
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();
1901
+ });
1425
1902
  }
1426
1903
  /** Replay the durable session log so a resumed session renders its history. */
1427
1904
  replayHistory() {
@@ -1447,7 +1924,7 @@ export class SshTui {
1447
1924
  if (providerUsesLocalOAuth(provider)) {
1448
1925
  this.pushRow({
1449
1926
  kind: 'system',
1450
- text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),使用本机 OAuth token,无需 API Key。如需改回 Key 提供商,输入 /setup。`,
1927
+ text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;只有要改成 DeepSeek 官方或 OpenCode 这类 Key 提供商时才需要 /setup。`,
1451
1928
  });
1452
1929
  this.markDirty();
1453
1930
  return;
@@ -1608,6 +2085,24 @@ export class SshTui {
1608
2085
  process.exit(code);
1609
2086
  }
1610
2087
  // ── terminal output ─────────────────────────────────────────────────────
2088
+ /** Capture one painted frame. Used by README screenshot fixtures. */
2089
+ captureFrame(columns = 80, rows = 24) {
2090
+ const previousColumns = process.stdout.columns;
2091
+ const previousRows = process.stdout.rows;
2092
+ const previousWrite = this.write.bind(this);
2093
+ this.write = () => { };
2094
+ process.stdout.columns = columns;
2095
+ process.stdout.rows = rows;
2096
+ try {
2097
+ this.paint();
2098
+ return [...this.lastPaintRows];
2099
+ }
2100
+ finally {
2101
+ this.write = previousWrite;
2102
+ process.stdout.columns = previousColumns;
2103
+ process.stdout.rows = previousRows;
2104
+ }
2105
+ }
1611
2106
  write(chunk) {
1612
2107
  process.stdout.write(chunk);
1613
2108
  }
@@ -1648,24 +2143,106 @@ export class SshTui {
1648
2143
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
1649
2144
  }
1650
2145
  findLivePlanRow() {
1651
- return this.rows.findLast((row) => row.kind === 'plan');
2146
+ return this.rows.findLast((row) => row.kind === 'plan' && planIsLive(row));
2147
+ }
2148
+ /** Older / finished plans stay in the scrolling transcript. */
2149
+ archiveStalePlans(keep) {
2150
+ for (const row of this.rows) {
2151
+ if (row.kind !== 'plan' || row === keep)
2152
+ continue;
2153
+ if (row.archived === true)
2154
+ continue;
2155
+ row.archived = true;
2156
+ row.active = false;
2157
+ row.pending = false;
2158
+ row.expanded = false;
2159
+ }
1652
2160
  }
1653
2161
  upsertPlanRow(patch) {
1654
2162
  const existing = this.findLivePlanRow();
1655
- if (existing !== undefined) {
2163
+ // A new docked plan only starts when the current one is no longer live
2164
+ // (completed / archived). Re-entering plan mode on the same incomplete
2165
+ // list must keep updating that row, not archive it.
2166
+ if (existing !== undefined && planIsLive(existing)) {
1656
2167
  Object.assign(existing, patch);
2168
+ existing.archived = false;
2169
+ if (!planIsLive(existing)) {
2170
+ existing.archived = true;
2171
+ existing.expanded = false;
2172
+ }
2173
+ this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
1657
2174
  return existing;
1658
2175
  }
2176
+ if (existing !== undefined) {
2177
+ existing.archived = true;
2178
+ existing.active = false;
2179
+ existing.pending = false;
2180
+ existing.expanded = false;
2181
+ }
1659
2182
  const row = {
1660
2183
  kind: 'plan',
1661
2184
  active: patch.active ?? false,
1662
2185
  pending: patch.pending ?? false,
1663
2186
  todos: patch.todos ?? [],
2187
+ ...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
1664
2188
  expanded: false,
2189
+ archived: false,
1665
2190
  };
1666
2191
  this.pushRow(row);
2192
+ this.archiveStalePlans(row);
1667
2193
  return row;
1668
2194
  }
2195
+ /** Whether the live plan strip should occupy the workspace footer. */
2196
+ shouldDockPlan() {
2197
+ return this.findLivePlanRow() !== undefined;
2198
+ }
2199
+ /** Compact web-style plan strip pinned above the input, not in the transcript. */
2200
+ paintPlanDock(width, yieldBottom) {
2201
+ const plan = this.findLivePlanRow();
2202
+ if (plan === undefined)
2203
+ return [];
2204
+ const inner = Math.max(1, width - 2);
2205
+ const running = plan.todos.some(item => item.status === 'in_progress');
2206
+ 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 ? '计划完成' : '计划';
2209
+ const counts = todoProgressLabel(plan.todos);
2210
+ const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
2211
+ const summary = title ?? (counts === '' ? '还没有任务' : counts);
2212
+ const marker = plan.expanded ? '▾' : '▸';
2213
+ const focused = this.focusedRow === plan ? '▶ ' : ' ';
2214
+ const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : ' · Enter 展开'}`;
2215
+ const lines = [this.styleLine('plan-dock', padToWidth(header, width))];
2216
+ if (yieldBottom || !plan.expanded)
2217
+ return lines;
2218
+ const note = planDockNote(plan);
2219
+ lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
2220
+ if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
2221
+ const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
2222
+ const budget = Math.max(4, Math.min(12, markdown.length));
2223
+ for (const line of markdown.slice(0, budget)) {
2224
+ lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
2225
+ }
2226
+ if (markdown.length > budget) {
2227
+ lines.push(this.styleLine('plan-dock', padToWidth(` … 还有 ${markdown.length - budget} 行计划`, width)));
2228
+ }
2229
+ }
2230
+ if (plan.todos.length === 0) {
2231
+ if (plan.planMarkdown === undefined || plan.planMarkdown === '') {
2232
+ lines.push(this.styleLine('todo-pending', padToWidth(' 还没有任务列表', width)));
2233
+ }
2234
+ }
2235
+ else {
2236
+ for (const item of plan.todos) {
2237
+ const mark = TODO_STATUS_MARK[item.status];
2238
+ const kind = todoItemKind(item.status);
2239
+ for (const wrapped of wrap(`${mark} ${item.content}`, inner)) {
2240
+ lines.push(this.styleLine(kind, padToWidth(` ${wrapped}`, width)));
2241
+ }
2242
+ }
2243
+ }
2244
+ return lines;
2245
+ }
1669
2246
  paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
1670
2247
  const focused = this.focusedRow === row;
1671
2248
  const marker = row.expanded ? '▾' : '▸';
@@ -1727,6 +2304,83 @@ export class SshTui {
1727
2304
  this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
1728
2305
  this.markDirty();
1729
2306
  }
2307
+ focusCard(row) {
2308
+ if (row === undefined)
2309
+ return;
2310
+ if (row.kind === 'assistant') {
2311
+ this.focusedRow = null;
2312
+ }
2313
+ else if ('expanded' in row) {
2314
+ this.focusedRow = row;
2315
+ }
2316
+ this.scrollOffset = 0;
2317
+ this.markDirty();
2318
+ }
2319
+ /** Jump to the newest card in a category (thinking / plan / subagent / reply). */
2320
+ jumpToCategory(category) {
2321
+ if (category === 'plan') {
2322
+ const live = this.findLivePlanRow();
2323
+ if (live !== undefined) {
2324
+ this.focusCard(live);
2325
+ this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
2326
+ return;
2327
+ }
2328
+ }
2329
+ const target = this.rows.findLast(row => cardCategoryOf(row) === category);
2330
+ if (target === undefined) {
2331
+ this.pushRow({ kind: 'system', text: `当前没有${CARD_CATEGORY_LABEL[category]}卡片。` });
2332
+ this.markDirty();
2333
+ return;
2334
+ }
2335
+ if ('expanded' in target)
2336
+ target.expanded = true;
2337
+ this.focusCard(target);
2338
+ this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
2339
+ }
2340
+ applySearchHits(query, hits) {
2341
+ this.searchQuery = query;
2342
+ this.searchHits = hits;
2343
+ if (hits.length === 0) {
2344
+ this.searchIndex = -1;
2345
+ this.pushRow({ kind: 'system', text: query === '' ? '没有可搜索的卡片。' : `没有匹配「${query}」的卡片。` });
2346
+ this.markDirty();
2347
+ return;
2348
+ }
2349
+ this.searchIndex = hits.length - 1;
2350
+ const hit = hits[this.searchIndex];
2351
+ if (hit !== undefined && 'expanded' in hit)
2352
+ hit.expanded = true;
2353
+ this.focusCard(hit);
2354
+ const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2355
+ this.pushRow({
2356
+ kind: 'system',
2357
+ text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
2358
+ });
2359
+ }
2360
+ runFindCommand(arg) {
2361
+ const parsed = parseFindQuery(arg);
2362
+ const label = parsed.category === undefined ? '' : `${CARD_CATEGORY_LABEL[parsed.category]} `;
2363
+ const hits = matchTranscriptRows(this.rows, arg);
2364
+ this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
2365
+ }
2366
+ stepSearch(delta) {
2367
+ if (this.searchHits.length === 0) {
2368
+ this.pushRow({ kind: 'system', text: '还没有搜索结果。用 /find 思考 padAnsi,或 Ctrl+/ 打开搜索。' });
2369
+ this.markDirty();
2370
+ return;
2371
+ }
2372
+ const count = this.searchHits.length;
2373
+ this.searchIndex = (this.searchIndex + delta + count) % count;
2374
+ const hit = this.searchHits[this.searchIndex];
2375
+ if (hit !== undefined && 'expanded' in hit)
2376
+ hit.expanded = true;
2377
+ this.focusCard(hit);
2378
+ const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
2379
+ this.pushRow({
2380
+ kind: 'system',
2381
+ text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
2382
+ });
2383
+ }
1730
2384
  paint = () => {
1731
2385
  if (this.exiting)
1732
2386
  return;
@@ -1766,7 +2420,7 @@ export class SshTui {
1766
2420
  const focused = this.focusedRow === row;
1767
2421
  const marker = row.expanded ? '▾' : '▸';
1768
2422
  const lines = row.text.split('\n').length;
1769
- const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Ctrl+R 展开'}`;
2423
+ const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
1770
2424
  const line = `${focused ? '▶ ' : ' '}${header}`;
1771
2425
  const styled = this.styleLine('reasoning', line);
1772
2426
  addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
@@ -1785,10 +2439,13 @@ export class SshTui {
1785
2439
  // "[33m" text on screen; color the dot between two sanitized halves.
1786
2440
  const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
1787
2441
  const styleToolHeader = (line) => {
1788
- const dotIndex = line.indexOf('●');
2442
+ const safe = sanitizeTerminalText(line);
2443
+ if (!this.color)
2444
+ return safe;
2445
+ const dotIndex = safe.indexOf('●');
1789
2446
  if (dotColor === undefined || dotIndex === -1)
1790
- return this.styleLine('tool', line);
1791
- return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
2447
+ return this.styleLine('tool', safe);
2448
+ return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
1792
2449
  };
1793
2450
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
1794
2451
  const state = running ? 'running…' : ok ? 'ok' : 'error';
@@ -1813,8 +2470,11 @@ export class SshTui {
1813
2470
  addDisplay(styleToolHeader(wrapped), row);
1814
2471
  }
1815
2472
  for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
1816
- for (const wrapped of wrap(line.text, Math.max(1, width - 2))) {
1817
- addDisplay(this.styleLine(line.kind, ` ${wrapped}`));
2473
+ const inner = Math.max(1, width - 2);
2474
+ const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
2475
+ for (const wrapped of wrap(line.text, inner)) {
2476
+ const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
2477
+ addDisplay(this.styleLine(line.kind, body));
1818
2478
  }
1819
2479
  }
1820
2480
  continue;
@@ -1824,10 +2484,13 @@ export class SshTui {
1824
2484
  const ok = row.status === 'ok';
1825
2485
  const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
1826
2486
  const styleHeader = (line) => {
1827
- const dotIndex = line.indexOf('●');
2487
+ const safe = sanitizeTerminalText(line);
2488
+ if (!this.color)
2489
+ return safe;
2490
+ const dotIndex = safe.indexOf('●');
1828
2491
  if (dotColor === undefined || dotIndex === -1)
1829
- return this.styleLine('tool', line);
1830
- return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
2492
+ return this.styleLine('tool', safe);
2493
+ return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
1831
2494
  };
1832
2495
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
1833
2496
  const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
@@ -1856,30 +2519,24 @@ export class SshTui {
1856
2519
  continue;
1857
2520
  }
1858
2521
  if (row.kind === 'plan') {
1859
- const running = row.todos.some(item => item.status === 'in_progress');
1860
- const spinner = (row.active || row.pending || running) ? ` ${this.spinnerFrame()}` : '';
1861
- const mode = row.pending
1862
- ? '切换中'
1863
- : row.active
1864
- ? '计划模式'
1865
- : '计划';
1866
- const header = `● ${mode}${spinner} · ${todoSummary(row.todos)}${row.expanded ? '' : ' · Enter 展开'}`;
1867
- this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
2522
+ if (planIsLive(row) && this.findLivePlanRow() === row)
2523
+ continue;
2524
+ const counts = todoProgressLabel(row.todos);
2525
+ const title = planTitleFromMarkdown(row.planMarkdown ?? '');
2526
+ const summary = title ?? (counts === '' ? '已归档' : counts);
2527
+ const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
2528
+ this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
1868
2529
  if (row.expanded) {
1869
- addDisplay(this.styleLine('system', row.active
1870
- ? ' 当前处于计划模式:只规划、不改代码,确认后再执行。'
1871
- : ' 计划模式已关闭。可用 /plan 重新进入。'));
1872
- if (row.pending)
1873
- addDisplay(this.styleLine('system', ' 模式切换将在下一步生效。'));
1874
- if (row.todos.length === 0) {
1875
- addDisplay(this.styleLine('tool-result', ' 还没有任务列表'));
2530
+ addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`));
2531
+ if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
2532
+ for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
2533
+ addDisplay(` ${line}`);
2534
+ }
1876
2535
  }
1877
- else {
1878
- for (const item of row.todos) {
1879
- const mark = TODO_STATUS_MARK[item.status];
1880
- for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
1881
- addDisplay(this.styleLine(item.status === 'completed' ? 'system' : 'tool', ` ${wrapped}`));
1882
- }
2536
+ for (const item of row.todos) {
2537
+ const mark = TODO_STATUS_MARK[item.status];
2538
+ for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
2539
+ addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`));
1883
2540
  }
1884
2541
  }
1885
2542
  }
@@ -1899,8 +2556,15 @@ export class SshTui {
1899
2556
  addDisplay(this.styleLine('assistant', ` ${wrapped}`));
1900
2557
  }
1901
2558
  if (row.detail !== undefined && row.detail !== '') {
1902
- for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
1903
- addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
2559
+ if (row.intent === 'plan-review') {
2560
+ for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
2561
+ addDisplay(` ${line}`);
2562
+ }
2563
+ }
2564
+ else {
2565
+ for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
2566
+ addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
2567
+ }
1904
2568
  }
1905
2569
  }
1906
2570
  addDisplay(this.styleLine('system', waiting
@@ -2035,7 +2699,9 @@ export class SshTui {
2035
2699
  addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
2036
2700
  addDialog(d.question.question);
2037
2701
  if (d.question.detail !== undefined && d.question.detail !== '') {
2038
- addDialog(truncate(d.question.detail, 12));
2702
+ for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
2703
+ dialogLines.push(this.styleLine('assistant', line));
2704
+ }
2039
2705
  }
2040
2706
  }
2041
2707
  else {
@@ -2064,12 +2730,11 @@ export class SshTui {
2064
2730
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
2065
2731
  const headerLines = [
2066
2732
  this.styleLine('system', fitLine(`DeepSeek Harness — SSH TUI [${this.presetName}] ${this.currentSelectionLabel()}`)),
2067
- this.styleLine('system', '─'.repeat(width)),
2733
+ this.styleLine('system', repeatToWidth('─', width)),
2068
2734
  ];
2069
2735
  if (this.scrollOffset > 0) {
2070
2736
  headerLines.push(this.styleLine('system', fitLine(`↑ 已回看 ${this.scrollOffset} 行 · PgUp/PgDn/滚轮滚动 · Esc 回到底部`)));
2071
2737
  }
2072
- const inputDivider = this.styleLine('system', '─'.repeat(width));
2073
2738
  this.commandSuggestions = this.dialog === undefined ? this.buildSuggestions() : [];
2074
2739
  if (this.suggestionIndex >= this.commandSuggestions.length) {
2075
2740
  this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
@@ -2087,7 +2752,7 @@ export class SshTui {
2087
2752
  const promptWidth = displayWidth(promptPlain);
2088
2753
  const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
2089
2754
  const inputView = masked
2090
- ? { text: '•'.repeat(this.input.length), cursorOffset: this.cursor, folded: false }
2755
+ ? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
2091
2756
  : this.inputFolded
2092
2757
  ? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
2093
2758
  : { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
@@ -2132,7 +2797,12 @@ export class SshTui {
2132
2797
  }
2133
2798
  }
2134
2799
  const inputRows = Math.max(1, inputDisplayLines.length);
2135
- const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + 1; // +1 input divider
2800
+ const yieldPlanDock = this.dialog !== undefined || suggestionLines.length > 0;
2801
+ const planDockLines = this.shouldDockPlan()
2802
+ ? this.paintPlanDock(width, yieldPlanDock)
2803
+ : [];
2804
+ const inputDivider = this.styleLine('system', repeatToWidth('─', width));
2805
+ const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
2136
2806
  const available = Math.max(1, height - reserved - dialogLines.length);
2137
2807
  const maxOffset = Math.max(0, display.length - available);
2138
2808
  if (this.scrollOffset > maxOffset)
@@ -2151,6 +2821,11 @@ export class SshTui {
2151
2821
  if (ref !== undefined)
2152
2822
  this.clickableRows.set(headerLines.length + index + 1, ref);
2153
2823
  }
2824
+ const dockPlan = this.findLivePlanRow();
2825
+ if (dockPlan !== undefined && planDockLines.length > 0) {
2826
+ const dockTop = headerLines.length + visible.length + 1;
2827
+ this.clickableRows.set(dockTop, dockPlan);
2828
+ }
2154
2829
  const statsText = this.statsText();
2155
2830
  const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
2156
2831
  let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
@@ -2160,6 +2835,9 @@ export class SshTui {
2160
2835
  statusText += sub.provider === undefined
2161
2836
  ? ` · sub:${sub.model}${subEffort}`
2162
2837
  : ` · sub:${subProvider}/${sub.model}${subEffort}`;
2838
+ if (this.searchHits.length > 0 && this.searchIndex >= 0) {
2839
+ statusText += ` · 搜索 ${this.searchIndex + 1}/${this.searchHits.length}`;
2840
+ }
2163
2841
  if (inputView.folded)
2164
2842
  statusText += ' · 输入已折叠 · Ctrl+T 展开';
2165
2843
  else if (inputRows > 1)
@@ -2196,6 +2874,7 @@ export class SshTui {
2196
2874
  const paintRows = [
2197
2875
  ...headerLines,
2198
2876
  ...visible,
2877
+ ...planDockLines,
2199
2878
  ...dialogLines,
2200
2879
  inputDivider,
2201
2880
  ...suggestionLines,
@@ -2206,7 +2885,7 @@ export class SshTui {
2206
2885
  // Bottom chrome is force-repainted whenever its state changes while the
2207
2886
  // agent is working; this clears any stale cell left behind by a previous
2208
2887
  // frame even when the row strings happen to be identical.
2209
- const chromeStart = Math.max(0, paintRows.length - inputRows - 3);
2888
+ const chromeStart = Math.max(0, paintRows.length - inputRows - suggestionLines.length - dialogLines.length - planDockLines.length - 3);
2210
2889
  const chromeKey = [
2211
2890
  this.status,
2212
2891
  this.agent.status,
@@ -2217,33 +2896,37 @@ export class SshTui {
2217
2896
  inputView.folded,
2218
2897
  inputRows,
2219
2898
  paintRows.length,
2899
+ width,
2900
+ height,
2220
2901
  this.pendingMessages.size,
2221
2902
  this.commandSuggestions.length,
2222
2903
  this.suggestionIndex,
2223
2904
  this.activeSubagents.size,
2224
2905
  this.dialog?.kind ?? '',
2225
- this.findLivePlanRow()?.active === true ? 'plan' : '',
2906
+ planDockLines.join('\n'),
2226
2907
  ].join('\x1f');
2227
2908
  const chromeChanged = chromeKey !== this.lastChromeKey;
2228
- // Incremental repaint: rewrite only rows whose content changed, so slow
2229
- // SSH links don't rebuild (and flicker) the whole screen on every tick.
2230
- this.write('\x1b[?25l');
2231
- const maxRows = Math.max(paintRows.length, this.lastPaintRows.length);
2232
- for (let i = 0; i < maxRows; i++) {
2233
- const current = paintRows[i];
2234
- if (current === this.lastPaintRows[i] && !(chromeChanged && i >= chromeStart))
2235
- continue;
2236
- this.write(`\x1b[${i + 1};1H\x1b[0m${current ?? ''}\x1b[K`);
2237
- }
2238
- if (paintRows.length < this.lastPaintRows.length) {
2239
- this.write(`\x1b[${paintRows.length + 1};1H\x1b[J`);
2240
- }
2241
- this.write('\x1b[0m');
2242
- this.lastPaintRows = paintRows;
2243
- this.lastChromeKey = chromeKey;
2244
- const inputTopRow = visible.length + dialogLines.length + suggestionLines.length + headerLines.length + 2;
2909
+ const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
2910
+ // One stdout write per frame: dirty rows only, so jump-host SSH sees a
2911
+ // single packet instead of one write per line. Clip/pad so leftover
2912
+ // wide glyphs cannot wrap into the input box.
2913
+ const inputTopRow = visible.length + planDockLines.length + dialogLines.length + suggestionLines.length + headerLines.length + 2;
2245
2914
  const row = Math.min(height, inputTopRow + cursorRowOffset);
2246
- this.write(`\x1b[${row};${Math.max(1, column)}H\x1b[?25h`);
2915
+ this.write(composePaintOutput({
2916
+ width,
2917
+ height,
2918
+ paintRows,
2919
+ previousRows: this.lastPaintRows,
2920
+ sizeChanged,
2921
+ chromeChanged,
2922
+ chromeStart,
2923
+ cursorRow: row,
2924
+ cursorColumn: column,
2925
+ }));
2926
+ this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
2927
+ this.lastChromeKey = chromeKey;
2928
+ this.lastPaintWidth = width;
2929
+ this.lastPaintHeight = height;
2247
2930
  };
2248
2931
  buildSuggestions() {
2249
2932
  const input = this.input;
@@ -2405,9 +3088,13 @@ export class SshTui {
2405
3088
  kind === 'diff-add' ? '38;5;22;48;5;194' :
2406
3089
  kind === 'diff-del' ? '38;5;124;48;5;224' :
2407
3090
  kind === 'diff-path' ? '1;36' :
2408
- kind === 'error' ? '31' :
2409
- '90';
2410
- return `\x1b[${code}m${safe}`;
3091
+ kind === 'todo-done' ? '2;32' :
3092
+ kind === 'todo-active' ? '1;36' :
3093
+ kind === 'todo-pending' ? '90' :
3094
+ kind === 'plan-dock' ? '38;5;180' :
3095
+ kind === 'error' ? '31' :
3096
+ '90';
3097
+ return `\x1b[${code}m${safe}\x1b[0m`;
2411
3098
  }
2412
3099
  // ── event handling ──────────────────────────────────────────────────────
2413
3100
  /**
@@ -2421,10 +3108,15 @@ export class SshTui {
2421
3108
  if (agent === this.agent)
2422
3109
  return resolved;
2423
3110
  const selection = this.subagentSelection.current;
3111
+ const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
3112
+ const provider = selection.provider ?? parentProvider;
3113
+ const model = subagentModelMatchesProvider(provider, selection.model)
3114
+ ? selection.model
3115
+ : defaultSubagentModelForProvider(provider);
2424
3116
  return {
2425
3117
  ...resolved,
2426
- ...(selection.provider === undefined ? {} : { provider: selection.provider }),
2427
- model: selection.model,
3118
+ provider,
3119
+ model,
2428
3120
  ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
2429
3121
  };
2430
3122
  };
@@ -2551,6 +3243,11 @@ export class SshTui {
2551
3243
  expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
2552
3244
  };
2553
3245
  this.pushRow(row);
3246
+ if (event.data.name === 'exit_plan_mode') {
3247
+ const markdown = planMarkdownFromArgs(event.data.arguments);
3248
+ if (markdown !== undefined)
3249
+ this.upsertPlanRow({ planMarkdown: markdown, expanded: false });
3250
+ }
2554
3251
  this.streaming = undefined;
2555
3252
  this.markDirty();
2556
3253
  break;
@@ -3096,13 +3793,13 @@ export class SshTui {
3096
3793
  this.removeQueuedDialog(dialog);
3097
3794
  dialog.resolve('cancel');
3098
3795
  }
3099
- openQuestion(question, index, total, resolve, reject) {
3796
+ openQuestion(question, index, total, resolve, reject, preselected) {
3100
3797
  const dialog = {
3101
3798
  kind: 'questions',
3102
3799
  question,
3103
3800
  index,
3104
3801
  total,
3105
- selected: new Set(),
3802
+ selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
3106
3803
  resolve: (selection) => {
3107
3804
  this.settleQuestion(dialog, () => resolve(selection));
3108
3805
  },
@@ -3114,9 +3811,9 @@ export class SshTui {
3114
3811
  return dialog;
3115
3812
  }
3116
3813
  /** Open one question dialog and await its answer (cancellation rejects). */
3117
- askQuestion(question, index = 0, total = 1) {
3814
+ askQuestion(question, index = 0, total = 1, preselected) {
3118
3815
  return new Promise((resolve, reject) => {
3119
- this.openQuestion(question, index, total, resolve, reject);
3816
+ this.openQuestion(question, index, total, resolve, reject, preselected);
3120
3817
  });
3121
3818
  }
3122
3819
  /** The stored llm-pi-ai profile for one provider route, when settings provide one. */
@@ -3238,11 +3935,12 @@ export class SshTui {
3238
3935
  options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
3239
3936
  if (hasNext)
3240
3937
  options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
3938
+ const currentIndex = page.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
3241
3939
  const answer = await this.askQuestion({
3242
3940
  id: 'model-pick',
3243
3941
  question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
3244
3942
  options,
3245
- });
3943
+ }, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
3246
3944
  const picked = options.find(option => option.label === answer.selected[0]);
3247
3945
  if (picked === undefined)
3248
3946
  return undefined;
@@ -3271,23 +3969,33 @@ export class SshTui {
3271
3969
  const display = name !== undefined && name !== '' && name !== id ? name : kind.short;
3272
3970
  out.push({ id, label: `${display} · ${id}` });
3273
3971
  };
3972
+ add(current);
3274
3973
  for (const info of llm?.listProviders() ?? [])
3275
3974
  add(info.id, info.name);
3276
- add(current);
3277
- add('deepseek-official', 'DeepSeek 官方');
3278
3975
  add('xai', 'SuperGrok');
3976
+ add('deepseek-official', 'DeepSeek 官方');
3279
3977
  add('opencode-go', 'OpenCode Go');
3280
3978
  add('opencode', 'OpenCode Zen');
3281
3979
  return out;
3282
3980
  }
3283
- /** /model: pick a provider, then a model and reasoning effort on that route. */
3981
+ /** Built-in SuperGrok catalog used when the live adapter list is still warming up. */
3982
+ static XAI_FALLBACK_MODELS = [
3983
+ { id: 'grok-4.6', label: 'Grok 4.6' },
3984
+ { id: 'grok-4.5', label: 'Grok 4.5' },
3985
+ { id: 'grok-4.3', label: 'Grok 4.3' },
3986
+ ];
3987
+ /** /model: stay on the current provider by default; switching providers is opt-in. */
3284
3988
  async runModelCommand() {
3285
3989
  const llm = this.ctx.get('llm');
3286
3990
  const current = this.selectionRef?.current;
3287
3991
  const providers = this.listSelectableProviders();
3288
3992
  let provider = this.currentProviderId();
3289
- if (providers.length > 1) {
3290
- const answer = await this.askQuestion({
3993
+ const SWITCH_PROVIDER_ID = '__switch_provider__';
3994
+ const pickProvider = async () => {
3995
+ if (providers.length <= 1)
3996
+ return provider;
3997
+ const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
3998
+ const pickedAnswer = await this.askQuestion({
3291
3999
  id: 'provider-pick',
3292
4000
  question: '选择提供商',
3293
4001
  options: providers.map(option => ({
@@ -3296,12 +4004,9 @@ export class SshTui {
3296
4004
  ? `${describeProviderRoute(option.id).kind} · 当前`
3297
4005
  : describeProviderRoute(option.id).kind,
3298
4006
  })),
3299
- });
3300
- const picked = providers.find(option => option.label === answer.selected[0]);
3301
- if (picked === undefined)
3302
- return;
3303
- provider = picked.id;
3304
- }
4007
+ }, 0, 1, currentIndex);
4008
+ return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
4009
+ };
3305
4010
  let modelOptions = [];
3306
4011
  let modelSource = '已配置列表';
3307
4012
  // OpenCode and other third-party routes are interrogated live so the picker
@@ -3348,42 +4053,115 @@ export class SshTui {
3348
4053
  modelOptions = [];
3349
4054
  }
3350
4055
  }
4056
+ if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4057
+ modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4058
+ modelSource = 'SuperGrok 目录';
4059
+ }
3351
4060
  if (modelOptions.length === 0) {
3352
- const fallback = current?.model ?? this.agent.options.model ?? 'deepseek-v4-flash';
4061
+ const fallback = current?.model ?? this.agent.options.model ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
3353
4062
  modelOptions = [{ id: fallback, label: fallback }];
3354
4063
  }
4064
+ if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
4065
+ modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
4066
+ }
4067
+ if (providers.length > 1) {
4068
+ modelOptions = [
4069
+ ...modelOptions,
4070
+ { id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
4071
+ ];
4072
+ }
3355
4073
  const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
3356
4074
  if (selected === undefined)
3357
4075
  return;
3358
- if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
4076
+ if (selected.id === SWITCH_PROVIDER_ID) {
4077
+ const nextProvider = await pickProvider();
4078
+ if (nextProvider === undefined || nextProvider === provider)
4079
+ return;
4080
+ provider = nextProvider;
4081
+ modelOptions = [];
4082
+ modelSource = '已配置列表';
4083
+ // Reload the model list for the newly chosen provider.
4084
+ if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
4085
+ try {
4086
+ modelOptions = await this.discoverEndpointModels(provider);
4087
+ if (modelOptions.length > 0)
4088
+ modelSource = '端点实时列表';
4089
+ }
4090
+ catch {
4091
+ modelOptions = [];
4092
+ }
4093
+ }
4094
+ if (modelOptions.length === 0) {
4095
+ try {
4096
+ const listed = (await llm?.listModels(provider)) ?? [];
4097
+ modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
4098
+ }
4099
+ catch {
4100
+ modelOptions = [];
4101
+ }
4102
+ }
4103
+ if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4104
+ modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
4105
+ modelSource = 'SuperGrok 目录';
4106
+ }
4107
+ if (modelOptions.length === 0) {
4108
+ const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
4109
+ modelOptions = [{ id: fallback, label: fallback }];
4110
+ }
4111
+ const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
4112
+ if (switched === undefined)
4113
+ return;
4114
+ return await this.applyModelSelection(provider, switched.id, undefined);
4115
+ }
4116
+ await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id).filter(id => id !== SWITCH_PROVIDER_ID));
4117
+ }
4118
+ /** Persist a provider/model/effort choice and keep the subagent on the same family. */
4119
+ async applyModelSelection(provider, modelId, listed = []) {
4120
+ if (!(await this.ensureProviderModelConfigured(provider, modelId)))
3359
4121
  return;
4122
+ const llm = this.ctx.get('llm');
4123
+ const current = this.selectionRef?.current;
3360
4124
  let effortOptions = [];
3361
4125
  try {
3362
- const info = await llm?.resolveModelInfo(provider, selected.id);
4126
+ const info = await llm?.resolveModelInfo(provider, modelId);
3363
4127
  effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
3364
4128
  }
3365
4129
  catch {
3366
4130
  effortOptions = [];
3367
4131
  }
3368
- if (effortOptions.length === 0) {
3369
- // No selectable reasoning effort for this model: do not invent
3370
- // `off/high/max`, which the adapter may reject for the exact model.
4132
+ if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
4133
+ effortOptions = modelId === 'grok-4.6'
4134
+ ? [
4135
+ { id: 'off', label: 'Off' },
4136
+ { id: 'low', label: 'Low' },
4137
+ { id: 'medium', label: 'Medium' },
4138
+ { id: 'high', label: 'High' },
4139
+ { id: 'xhigh', label: 'Extra high' },
4140
+ ]
4141
+ : [
4142
+ { id: 'off', label: 'Off' },
4143
+ { id: 'low', label: 'Low' },
4144
+ { id: 'medium', label: 'Medium' },
4145
+ { id: 'high', label: 'High' },
4146
+ ];
3371
4147
  }
3372
4148
  let effort;
3373
4149
  if (effortOptions.length > 0) {
4150
+ const currentEffort = current?.provider === provider ? String(current?.reasoningEffort ?? '') : '';
4151
+ const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
3374
4152
  const effortAnswer = await this.askQuestion({
3375
4153
  id: 'effort-pick',
3376
- question: `选择思考强度(${selected.id})`,
4154
+ question: `选择思考强度(${modelId})`,
3377
4155
  options: effortOptions.map(option => ({
3378
4156
  label: option.label,
3379
- description: option.id === String(current?.reasoningEffort) ? '当前' : undefined,
4157
+ description: option.id === currentEffort ? '当前' : undefined,
3380
4158
  })),
3381
- });
4159
+ }, 0, 1, currentIndex);
3382
4160
  effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
3383
4161
  }
3384
4162
  const next = {
3385
4163
  provider,
3386
- model: selected.id,
4164
+ model: modelId,
3387
4165
  ...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
3388
4166
  };
3389
4167
  if (this.selectionRef !== undefined)
@@ -3393,8 +4171,9 @@ export class SshTui {
3393
4171
  const kind = describeProviderRoute(provider);
3394
4172
  this.pushRow({
3395
4173
  kind: 'system',
3396
- text: `已切换到 ${kind.kind}:${provider}/${selected.id}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
4174
+ text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
3397
4175
  });
4176
+ await this.syncSubagentToProvider(provider, listed.filter(id => id !== '__switch_provider__' && id !== ''));
3398
4177
  this.markDirty();
3399
4178
  }
3400
4179
  /** Provider route the next subagent request should use. */
@@ -3404,6 +4183,40 @@ export class SshTui {
3404
4183
  ?? this.agent.options.provider
3405
4184
  ?? this.providerName;
3406
4185
  }
4186
+ /**
4187
+ * When the parent provider changes (OAuth or API key), keep the subagent
4188
+ * on a same-family model. An explicit leftover DeepSeek flash id after
4189
+ * switching to xAI is treated as stale.
4190
+ */
4191
+ async syncSubagentToProvider(provider, listed = []) {
4192
+ const current = this.subagentSelection.current;
4193
+ if (current.provider !== undefined && current.provider !== provider)
4194
+ return;
4195
+ if (subagentModelMatchesProvider(provider, current.model, listed))
4196
+ return;
4197
+ let catalog = [...listed];
4198
+ if (catalog.length === 0) {
4199
+ try {
4200
+ const { options } = await this.subagentModelOptions(provider);
4201
+ catalog = options.map(option => option.id);
4202
+ }
4203
+ catch {
4204
+ catalog = [];
4205
+ }
4206
+ }
4207
+ const nextModel = defaultSubagentModelForProvider(provider, catalog);
4208
+ if (nextModel === current.model)
4209
+ return;
4210
+ const persisted = await this.saveSubagentSelection({
4211
+ ...current,
4212
+ model: nextModel,
4213
+ reasoningEffort: undefined,
4214
+ });
4215
+ this.pushRow({
4216
+ kind: 'system',
4217
+ text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
4218
+ });
4219
+ }
3407
4220
  /** Persist one subagent selection and publish it to the live request waterfall. */
3408
4221
  async saveSubagentSelection(next) {
3409
4222
  this.subagentSelection.current = next;
@@ -3879,9 +4692,39 @@ export class SshTui {
3879
4692
  return;
3880
4693
  }
3881
4694
  if (combined.startsWith('\x1b') && combined.length > 1) {
3882
- // Alt+<key> sequences: ignore the ESC half instead of triggering cancel,
3883
- // and type the printable remainder.
3884
- this.handlePlainText(combined.slice(1));
4695
+ const alt = combined.slice(1);
4696
+ if (alt === '1') {
4697
+ this.jumpToCategory('thinking');
4698
+ return;
4699
+ }
4700
+ if (alt === '2') {
4701
+ this.jumpToCategory('plan');
4702
+ return;
4703
+ }
4704
+ if (alt === '3') {
4705
+ this.jumpToCategory('subagent');
4706
+ return;
4707
+ }
4708
+ if (alt === '4') {
4709
+ this.jumpToCategory('reply');
4710
+ return;
4711
+ }
4712
+ if (alt === 'n' || alt === 'N') {
4713
+ this.stepSearch(1);
4714
+ return;
4715
+ }
4716
+ if (alt === 'p' || alt === 'P') {
4717
+ this.stepSearch(-1);
4718
+ return;
4719
+ }
4720
+ if (alt === 'f' || alt === 'F' || alt === '/') {
4721
+ this.input = '/find ';
4722
+ this.cursor = this.input.length;
4723
+ this.markDirty();
4724
+ return;
4725
+ }
4726
+ // Other Alt+<key>: ignore ESC so it does not cancel, type the remainder.
4727
+ this.handlePlainText(alt);
3885
4728
  return;
3886
4729
  }
3887
4730
  this.handlePlainText(combined);
@@ -3980,6 +4823,10 @@ export class SshTui {
3980
4823
  void this.requestExit(0);
3981
4824
  return;
3982
4825
  case '\x0c':
4826
+ this.lastPaintRows = [];
4827
+ this.lastChromeKey = '';
4828
+ this.lastPaintWidth = 0;
4829
+ this.lastPaintHeight = 0;
3983
4830
  this.dirty = true;
3984
4831
  this.render();
3985
4832
  return;
@@ -4019,6 +4866,16 @@ export class SshTui {
4019
4866
  this.handleDialogChar(char);
4020
4867
  return;
4021
4868
  }
4869
+ if (char === '\x07') {
4870
+ this.stepSearch(1);
4871
+ return;
4872
+ }
4873
+ if (char === '\x1f') {
4874
+ this.input = '/find ';
4875
+ this.cursor = this.input.length;
4876
+ this.markDirty();
4877
+ return;
4878
+ }
4022
4879
  if (char === '\t') {
4023
4880
  if (this.suggestionsVisible()) {
4024
4881
  const selected = this.commandSuggestions[this.suggestionIndex];
@@ -4307,6 +5164,7 @@ export class SshTui {
4307
5164
  this.selectionRef.current = { provider: 'deepseek-official', model };
4308
5165
  }
4309
5166
  this.onSelectionChanged?.({ provider: 'deepseek-official', model });
5167
+ await this.syncSubagentToProvider('deepseek-official', state.models);
4310
5168
  if (state.baseUrl !== '' && settings !== undefined) {
4311
5169
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
4312
5170
  this.pushRow({ kind: 'system', text: `Base URL 已保存 → ${displayDshPath('settings.yaml')}` });
@@ -4369,6 +5227,7 @@ export class SshTui {
4369
5227
  this.selectionRef.current = selection;
4370
5228
  }
4371
5229
  this.onSelectionChanged?.(selection);
5230
+ await this.syncSubagentToProvider(state.providerId, state.models);
4372
5231
  this.pushRow({
4373
5232
  kind: 'system',
4374
5233
  text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
@@ -4623,10 +5482,12 @@ export class SshTui {
4623
5482
  ...local,
4624
5483
  ...dsh,
4625
5484
  '',
4626
- '运行中按 Enter 可插入指示;Esc / Ctrl+C 取消当前轮次。',
4627
- '↑/↓ Ctrl+N/P 选择思考、工具、子代理、计划或提问卡片;Enter 展开/折叠;Ctrl+R 全部展开或收起。',
4628
- '计划模式、提问用户和当前目标会显示独立卡片;多个子代理默认各自折叠,互不混排。',
4629
- '/model 先选提供商(DeepSeek 官方 / SuperGrok 订阅 / OpenCode …),再选模型和思考强度。',
5485
+ '运行中按 Enter 可插入指示;Esc 取消选择或当前轮次;空闲 Ctrl+C 退出。',
5486
+ '空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
5487
+ 'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
5488
+ '/find [思考|计划|子代理|回复] 关键字;Ctrl+/ Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
5489
+ '/model 默认列出当前提供商的模型;当前是 SuperGrok 时直接选 grok-4.6 / grok-4.5 和思考强度(含 xhigh)。要换提供商再选「更换提供商」。',
5490
+ '/setup 只用于配置 API Key 提供商。SuperGrok / X Premium 走本机 OAuth,不需要填 Key。',
4630
5491
  '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
4631
5492
  ].join('\n'),
4632
5493
  });
@@ -4680,12 +5541,18 @@ export class SshTui {
4680
5541
  this.markDirty();
4681
5542
  });
4682
5543
  break;
5544
+ case 'find':
5545
+ this.runFindCommand(arg);
5546
+ break;
4683
5547
  case 'clear':
4684
5548
  this.rows.length = 0;
4685
5549
  this.streaming = undefined;
4686
5550
  this.streamingReasoning = undefined;
4687
5551
  this.thinkingStartedAt = undefined;
4688
5552
  this.focusedRow = null;
5553
+ this.searchHits = [];
5554
+ this.searchIndex = -1;
5555
+ this.searchQuery = '';
4689
5556
  this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
4690
5557
  break;
4691
5558
  case 'status':
@@ -4753,7 +5620,7 @@ export class SshTui {
4753
5620
  const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
4754
5621
  return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
4755
5622
  });
4756
- this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n↑/↓ 选择对应卡片,Enter 展开/折叠,Ctrl+R 全部展开或收起。` });
5623
+ this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n空输入时 ↑/↓ 选卡片,Enter 展开;Alt+3 跳到最新子代理。` });
4757
5624
  }
4758
5625
  break;
4759
5626
  }