dsh-ssh-tui 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +87 -40
- package/README.md +71 -31
- package/cordis.patch.yml +2 -0
- package/docs/screenshots/compare.png +0 -0
- package/docs/screenshots/headless.png +0 -0
- package/docs/screenshots/workspace.png +0 -0
- package/lib/index.js.map +1 -1
- package/lib/reasoning.js +5 -3
- package/lib/reasoning.js.map +1 -1
- package/lib/subagent-model.js +82 -2
- package/lib/subagent-model.js.map +1 -1
- package/lib/tui.js +1099 -139
- package/lib/tui.js.map +1 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/subagent-model.d.ts +17 -2
- package/lib/types/tui.d.ts +149 -5
- package/package.json +6 -2
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 160).
|
|
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: {
|
|
@@ -59,8 +59,48 @@ const PROVIDER_TEMPLATES = {
|
|
|
59
59
|
defaultModels: ['deepseek-v4-flash'],
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
|
-
const RENDER_INTERVAL_MS =
|
|
62
|
+
const RENDER_INTERVAL_MS = 160;
|
|
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: '
|
|
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
|
-
|
|
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 >=
|
|
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) >
|
|
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 >
|
|
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
|
|
368
|
-
//
|
|
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: '─'
|
|
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,193 @@ 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
|
+
/** Open todos left behind when a turn ends without a completing todo_write. */
|
|
1090
|
+
export function planTurnLeftOpen(plan) {
|
|
1091
|
+
return plan.todos.some(item => item.status !== 'completed');
|
|
1092
|
+
}
|
|
1093
|
+
/** Mark leftover in-progress/pending todos as display-stale after turn/end. */
|
|
1094
|
+
export function applyTurnEndToPlan(plan) {
|
|
1095
|
+
if (!planTurnLeftOpen(plan)) {
|
|
1096
|
+
plan.turnLeftOpen = false;
|
|
1097
|
+
return plan;
|
|
1098
|
+
}
|
|
1099
|
+
plan.turnLeftOpen = true;
|
|
1100
|
+
return plan;
|
|
1101
|
+
}
|
|
1102
|
+
/** Follow-up that asks the model to close leftover todos. One per open list. */
|
|
1103
|
+
export function planCloseNudgeText(plan) {
|
|
1104
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed');
|
|
1105
|
+
const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
|
|
1106
|
+
return [
|
|
1107
|
+
'本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
|
|
1108
|
+
...lines,
|
|
1109
|
+
].join('\n');
|
|
1110
|
+
}
|
|
1111
|
+
/** Category for jump / search. Assistant replies are not collapsible cards. */
|
|
1112
|
+
export function cardCategoryOf(row) {
|
|
1113
|
+
if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
|
|
1114
|
+
return 'thinking';
|
|
1115
|
+
if (row.kind === 'plan')
|
|
1116
|
+
return 'plan';
|
|
1117
|
+
if (row.kind === 'subagent')
|
|
1118
|
+
return 'subagent';
|
|
1119
|
+
if (row.kind === 'assistant')
|
|
1120
|
+
return 'reply';
|
|
1121
|
+
if (row.kind === 'tool')
|
|
1122
|
+
return 'tool';
|
|
1123
|
+
if (row.kind === 'question')
|
|
1124
|
+
return 'question';
|
|
1125
|
+
if (row.kind === 'goal')
|
|
1126
|
+
return 'goal';
|
|
1127
|
+
return undefined;
|
|
1128
|
+
}
|
|
1129
|
+
const CARD_CATEGORY_LABEL = {
|
|
1130
|
+
thinking: '思考',
|
|
1131
|
+
plan: '计划',
|
|
1132
|
+
subagent: '子代理',
|
|
1133
|
+
reply: '回复',
|
|
1134
|
+
tool: '工具',
|
|
1135
|
+
question: '提问',
|
|
1136
|
+
goal: '目标',
|
|
1137
|
+
};
|
|
1138
|
+
const SEARCHABLE_CATEGORIES = ['thinking', 'plan', 'subagent', 'reply'];
|
|
1139
|
+
function parseCardCategoryToken(token) {
|
|
1140
|
+
const id = token.trim().toLowerCase();
|
|
1141
|
+
if (id === 'thinking' || id === 'think' || id === '推理' || id === '思考')
|
|
1142
|
+
return 'thinking';
|
|
1143
|
+
if (id === 'plan' || id === '计划')
|
|
1144
|
+
return 'plan';
|
|
1145
|
+
if (id === 'subagent' || id === 'sub' || id === '子代理')
|
|
1146
|
+
return 'subagent';
|
|
1147
|
+
if (id === 'reply' || id === 'assistant' || id === '回复')
|
|
1148
|
+
return 'reply';
|
|
1149
|
+
if (id === 'tool' || id === '工具')
|
|
1150
|
+
return 'tool';
|
|
1151
|
+
if (id === 'question' || id === '提问')
|
|
1152
|
+
return 'question';
|
|
1153
|
+
if (id === 'goal' || id === '目标')
|
|
1154
|
+
return 'goal';
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
/** Split `/find thinking padAnsi` into an optional category and a query. */
|
|
1158
|
+
export function parseFindQuery(raw) {
|
|
1159
|
+
const text = raw.trim();
|
|
1160
|
+
if (text === '')
|
|
1161
|
+
return { query: '' };
|
|
1162
|
+
const match = /^(\S+)(?:\s+(.*))?$/u.exec(text);
|
|
1163
|
+
if (match === null)
|
|
1164
|
+
return { query: text };
|
|
1165
|
+
const category = parseCardCategoryToken(match[1] ?? '');
|
|
1166
|
+
if (category === undefined)
|
|
1167
|
+
return { query: text };
|
|
1168
|
+
return { category, query: (match[2] ?? '').trim() };
|
|
1169
|
+
}
|
|
1170
|
+
function rowSearchHaystack(row) {
|
|
1171
|
+
switch (row.kind) {
|
|
1172
|
+
case 'reasoning':
|
|
1173
|
+
case 'assistant':
|
|
1174
|
+
case 'user':
|
|
1175
|
+
case 'system':
|
|
1176
|
+
case 'error':
|
|
1177
|
+
case 'brand':
|
|
1178
|
+
return row.text;
|
|
1179
|
+
case 'tool':
|
|
1180
|
+
return `${row.title} ${row.summary} ${row.output} ${row.args}`;
|
|
1181
|
+
case 'subagent':
|
|
1182
|
+
return `${row.label} ${row.lastActivity} ${row.logs.map(entry => entry.text).join('\n')}`;
|
|
1183
|
+
case 'plan':
|
|
1184
|
+
return `${row.planMarkdown ?? ''} ${row.todos.map(item => item.content).join('\n')}`;
|
|
1185
|
+
case 'question':
|
|
1186
|
+
return `${row.title} ${row.summary} ${row.detail ?? ''} ${row.header ?? ''}`;
|
|
1187
|
+
case 'goal':
|
|
1188
|
+
return `${row.objective} ${row.blockedReason ?? ''}`;
|
|
1189
|
+
default:
|
|
1190
|
+
return '';
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/** Transcript rows matching a `/find` query, newest last. */
|
|
1194
|
+
export function matchTranscriptRows(rows, raw) {
|
|
1195
|
+
const { category, query } = parseFindQuery(raw);
|
|
1196
|
+
const needle = query.toLowerCase();
|
|
1197
|
+
return rows.filter(row => {
|
|
1198
|
+
const kind = cardCategoryOf(row);
|
|
1199
|
+
if (kind === undefined)
|
|
1200
|
+
return false;
|
|
1201
|
+
if (category !== undefined && kind !== category)
|
|
1202
|
+
return false;
|
|
1203
|
+
if (needle === '')
|
|
1204
|
+
return SEARCHABLE_CATEGORIES.includes(kind) || category !== undefined;
|
|
1205
|
+
return rowSearchHaystack(row).toLowerCase().includes(needle);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
/** One-line note under an expanded plan strip. */
|
|
1209
|
+
export function planDockNote(plan) {
|
|
1210
|
+
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
1211
|
+
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
1212
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed').length;
|
|
1213
|
+
if (plan.turnLeftOpen === true && leftover > 0) {
|
|
1214
|
+
return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
|
|
1215
|
+
}
|
|
1216
|
+
if (plan.pending)
|
|
1217
|
+
return '模式切换将在下一步生效。';
|
|
1218
|
+
if (plan.active)
|
|
1219
|
+
return '只规划、不改代码;确认后再执行。';
|
|
1220
|
+
if (running)
|
|
1221
|
+
return '正在按计划执行。';
|
|
1222
|
+
if (allDone)
|
|
1223
|
+
return '计划任务已全部完成。';
|
|
1224
|
+
if (plan.todos.length > 0 || (plan.planMarkdown !== undefined && plan.planMarkdown !== '')) {
|
|
1225
|
+
return '计划还在,尚未全部完成。';
|
|
1226
|
+
}
|
|
1227
|
+
return '计划模式已关闭,可用 /plan 重新进入。';
|
|
1228
|
+
}
|
|
1229
|
+
/** Compact per-status counts matching the web plan strip. */
|
|
1230
|
+
export function todoProgressLabel(todos) {
|
|
1231
|
+
const done = todos.filter(item => item.status === 'completed').length;
|
|
1232
|
+
const active = todos.filter(item => item.status === 'in_progress').length;
|
|
1233
|
+
const pending = todos.length - done - active;
|
|
1234
|
+
const parts = [];
|
|
1235
|
+
if (done > 0)
|
|
1236
|
+
parts.push(`${done} 已完成`);
|
|
1237
|
+
if (active > 0)
|
|
1238
|
+
parts.push(`${active} 进行中`);
|
|
1239
|
+
if (pending > 0)
|
|
1240
|
+
parts.push(`${pending} 待处理`);
|
|
1241
|
+
return parts.join(' · ');
|
|
1242
|
+
}
|
|
1243
|
+
function todoItemKind(status) {
|
|
1244
|
+
if (status === 'completed')
|
|
1245
|
+
return 'todo-done';
|
|
1246
|
+
if (status === 'in_progress')
|
|
1247
|
+
return 'todo-active';
|
|
1248
|
+
return 'todo-pending';
|
|
1249
|
+
}
|
|
1250
|
+
function planMarkdownFromArgs(value) {
|
|
1251
|
+
const root = typeof value === 'string' ? parseJsonArgs(value) : value;
|
|
1252
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root))
|
|
1253
|
+
return undefined;
|
|
1254
|
+
const plan = root.plan;
|
|
1255
|
+
return typeof plan === 'string' && plan.trim() !== '' ? plan : undefined;
|
|
1256
|
+
}
|
|
1257
|
+
/** First markdown heading of an exit_plan_mode plan body. */
|
|
1258
|
+
export function planTitleFromMarkdown(markdown) {
|
|
1259
|
+
const match = /^\s*#\s+(.+)$/mu.exec(markdown);
|
|
1260
|
+
const title = match?.[1]?.trim();
|
|
1261
|
+
return title === undefined || title === '' ? undefined : title;
|
|
1262
|
+
}
|
|
883
1263
|
/** Parse a todo_write payload into displayable plan items. */
|
|
884
1264
|
export function parsePlanTodos(value) {
|
|
885
1265
|
const root = typeof value === 'string' ? parseJsonArgs(value) : value;
|
|
@@ -1025,13 +1405,40 @@ export function presentToolCall(name, args) {
|
|
|
1025
1405
|
};
|
|
1026
1406
|
}
|
|
1027
1407
|
if (name === 'todo_write' || name === 'todo') {
|
|
1028
|
-
return { title: '
|
|
1408
|
+
return { title: '更新待办', summary: todoSummary(parsed) };
|
|
1029
1409
|
}
|
|
1030
1410
|
if (name === 'ask_user_question') {
|
|
1031
1411
|
return { title: '提问用户', summary: askSummary(parsed) };
|
|
1032
1412
|
}
|
|
1033
1413
|
if (name === 'exit_plan_mode') {
|
|
1034
|
-
|
|
1414
|
+
const plan = typeof parsed?.plan === 'string' ? parsed.plan : '';
|
|
1415
|
+
return { title: '提交计划', summary: planTitleFromMarkdown(plan) ?? '等待确认计划' };
|
|
1416
|
+
}
|
|
1417
|
+
if (name === 'read') {
|
|
1418
|
+
const path = typeof parsed?.path === 'string' ? parsed.path
|
|
1419
|
+
: typeof parsed?.file_path === 'string' ? parsed.file_path
|
|
1420
|
+
: typeof parsed?.url === 'string' ? parsed.url
|
|
1421
|
+
: '';
|
|
1422
|
+
return { title: '读取', summary: path || friendlyArgsSummary(name, args) };
|
|
1423
|
+
}
|
|
1424
|
+
if (name === 'grep') {
|
|
1425
|
+
const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern : '';
|
|
1426
|
+
const path = typeof parsed?.path === 'string' ? parsed.path : '';
|
|
1427
|
+
return { title: '搜索', summary: [pattern, path].filter(Boolean).join(' ') || friendlyArgsSummary(name, args) };
|
|
1428
|
+
}
|
|
1429
|
+
if (name === 'glob') {
|
|
1430
|
+
const pattern = typeof parsed?.pattern === 'string' ? parsed.pattern
|
|
1431
|
+
: typeof parsed?.glob_pattern === 'string' ? parsed.glob_pattern
|
|
1432
|
+
: '';
|
|
1433
|
+
return { title: '匹配文件', summary: pattern || friendlyArgsSummary(name, args) };
|
|
1434
|
+
}
|
|
1435
|
+
if (name === 'web_search') {
|
|
1436
|
+
const query = typeof parsed?.query === 'string' ? parsed.query : typeof parsed?.q === 'string' ? parsed.q : '';
|
|
1437
|
+
return { title: '网页搜索', summary: query || friendlyArgsSummary(name, args) };
|
|
1438
|
+
}
|
|
1439
|
+
if (name === 'web_fetch') {
|
|
1440
|
+
const url = typeof parsed?.url === 'string' ? parsed.url : '';
|
|
1441
|
+
return { title: '抓取网页', summary: url || friendlyArgsSummary(name, args) };
|
|
1035
1442
|
}
|
|
1036
1443
|
return { title: name, summary: friendlyArgsSummary(name, args) };
|
|
1037
1444
|
}
|
|
@@ -1212,6 +1619,9 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1212
1619
|
}
|
|
1213
1620
|
return out;
|
|
1214
1621
|
}
|
|
1622
|
+
const specialized = specializedToolBody(row);
|
|
1623
|
+
if (specialized !== null)
|
|
1624
|
+
return capDisplayLines(specialized, maxLines);
|
|
1215
1625
|
const out = [];
|
|
1216
1626
|
const args = parseJsonArgs(row.args);
|
|
1217
1627
|
if (args !== null && Object.keys(args).length > 0) {
|
|
@@ -1236,6 +1646,86 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1236
1646
|
}
|
|
1237
1647
|
return capDisplayLines(out, maxLines);
|
|
1238
1648
|
}
|
|
1649
|
+
function firstString(record, keys) {
|
|
1650
|
+
for (const key of keys) {
|
|
1651
|
+
const value = record[key];
|
|
1652
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
1653
|
+
return value;
|
|
1654
|
+
}
|
|
1655
|
+
return '';
|
|
1656
|
+
}
|
|
1657
|
+
function specializedToolBody(row) {
|
|
1658
|
+
const name = row.name ?? '';
|
|
1659
|
+
const args = parseJsonArgs(row.args);
|
|
1660
|
+
if (name === 'todo_write' || name === 'todo') {
|
|
1661
|
+
const todos = parsePlanTodos(args ?? row.args);
|
|
1662
|
+
const out = [{ kind: 'diff-path', text: todoProgressLabel(todos) || '待办列表' }];
|
|
1663
|
+
if (todos.length === 0) {
|
|
1664
|
+
out.push({ kind: 'tool-result', text: '还没有任务' });
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
for (const item of todos) {
|
|
1668
|
+
out.push({ kind: todoItemKind(item.status), text: `${TODO_STATUS_MARK[item.status]} ${item.content}` });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return out;
|
|
1672
|
+
}
|
|
1673
|
+
if (name === 'exit_plan_mode') {
|
|
1674
|
+
const markdown = planMarkdownFromArgs(args ?? row.args) ?? '';
|
|
1675
|
+
const out = [{ kind: 'diff-path', text: planTitleFromMarkdown(markdown) ?? '待审计划' }];
|
|
1676
|
+
if (markdown === '') {
|
|
1677
|
+
out.push({ kind: 'tool-result', text: '计划正文为空' });
|
|
1678
|
+
}
|
|
1679
|
+
else {
|
|
1680
|
+
for (const line of markdown.split('\n')) {
|
|
1681
|
+
out.push({ kind: 'assistant', text: line });
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return out;
|
|
1685
|
+
}
|
|
1686
|
+
if (name === 'read' && args !== null) {
|
|
1687
|
+
const path = firstString(args, ['path', 'file_path', 'url']);
|
|
1688
|
+
const out = [];
|
|
1689
|
+
if (path !== '')
|
|
1690
|
+
out.push({ kind: 'diff-path', text: path });
|
|
1691
|
+
const offset = typeof args.offset === 'number' ? args.offset : undefined;
|
|
1692
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
1693
|
+
if (offset !== undefined || limit !== undefined) {
|
|
1694
|
+
out.push({ kind: 'tool-result', text: `offset ${offset ?? 1}${limit === undefined ? '' : ` · limit ${limit}`}` });
|
|
1695
|
+
}
|
|
1696
|
+
if (row.output !== '') {
|
|
1697
|
+
for (const line of truncate(row.output, 40).split('\n')) {
|
|
1698
|
+
out.push({ kind: 'tool-result', text: line });
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
else if (row.status === 'running') {
|
|
1702
|
+
out.push({ kind: 'tool-result', text: '读取中…' });
|
|
1703
|
+
}
|
|
1704
|
+
return out.length > 0 ? out : null;
|
|
1705
|
+
}
|
|
1706
|
+
if ((name === 'grep' || name === 'glob') && args !== null) {
|
|
1707
|
+
const pattern = firstString(args, ['pattern', 'glob_pattern', 'query']);
|
|
1708
|
+
const path = firstString(args, ['path', 'glob']);
|
|
1709
|
+
const out = [{ kind: 'diff-path', text: [pattern, path].filter(Boolean).join(' ') || name }];
|
|
1710
|
+
if (row.output !== '') {
|
|
1711
|
+
for (const line of truncate(row.output, 30).split('\n')) {
|
|
1712
|
+
out.push({ kind: 'tool-result', text: line });
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return out;
|
|
1716
|
+
}
|
|
1717
|
+
if ((name === 'web_search' || name === 'web_fetch') && args !== null) {
|
|
1718
|
+
const query = firstString(args, ['query', 'q', 'url']);
|
|
1719
|
+
const out = [{ kind: 'diff-path', text: query || name }];
|
|
1720
|
+
if (row.output !== '') {
|
|
1721
|
+
for (const line of truncate(row.output, 24).split('\n')) {
|
|
1722
|
+
out.push({ kind: 'assistant', text: line });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return out;
|
|
1726
|
+
}
|
|
1727
|
+
return null;
|
|
1728
|
+
}
|
|
1239
1729
|
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
1240
1730
|
export function parseExitStatus(text) {
|
|
1241
1731
|
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
|
|
@@ -1346,6 +1836,14 @@ export class SshTui {
|
|
|
1346
1836
|
lastTitleUpdateAt = 0;
|
|
1347
1837
|
lastPaintRows = [];
|
|
1348
1838
|
lastChromeKey = '';
|
|
1839
|
+
lastPaintWidth = 0;
|
|
1840
|
+
lastPaintHeight = 0;
|
|
1841
|
+
paintIntervalMs;
|
|
1842
|
+
searchHits = [];
|
|
1843
|
+
searchIndex = -1;
|
|
1844
|
+
searchQuery = '';
|
|
1845
|
+
planNudgePending = false;
|
|
1846
|
+
pendingReveal;
|
|
1349
1847
|
constructor(ctx, agent, config) {
|
|
1350
1848
|
this.ctx = ctx;
|
|
1351
1849
|
this.agent = agent;
|
|
@@ -1366,9 +1864,10 @@ export class SshTui {
|
|
|
1366
1864
|
this.presetId = config.presetId ?? 'standard';
|
|
1367
1865
|
this.presetName = config.presetName ?? this.presetId;
|
|
1368
1866
|
this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
|
|
1867
|
+
this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs);
|
|
1369
1868
|
this.pushRow({ kind: 'brand-logo' });
|
|
1370
1869
|
this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
|
|
1371
|
-
this.pushRow({ kind: 'system', text: '输入 /help
|
|
1870
|
+
this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
|
|
1372
1871
|
}
|
|
1373
1872
|
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
1374
1873
|
start() {
|
|
@@ -1397,7 +1896,7 @@ export class SshTui {
|
|
|
1397
1896
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
1398
1897
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
1399
1898
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
|
|
1400
|
-
if (animating && now - this.lastPaintAt >= 200) {
|
|
1899
|
+
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1401
1900
|
this.dirty = true;
|
|
1402
1901
|
}
|
|
1403
1902
|
// While a turn is waiting on the provider with no new events, repaint at
|
|
@@ -1414,7 +1913,7 @@ export class SshTui {
|
|
|
1414
1913
|
this.lastPaintAt = now;
|
|
1415
1914
|
this.render();
|
|
1416
1915
|
}
|
|
1417
|
-
},
|
|
1916
|
+
}, this.paintIntervalMs);
|
|
1418
1917
|
this.renderTimer.unref?.();
|
|
1419
1918
|
void this.maybeRunOnboarding().catch((error) => {
|
|
1420
1919
|
if (this.disposed)
|
|
@@ -1422,6 +1921,12 @@ export class SshTui {
|
|
|
1422
1921
|
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
1423
1922
|
this.markDirty();
|
|
1424
1923
|
});
|
|
1924
|
+
void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
|
|
1925
|
+
if (this.disposed)
|
|
1926
|
+
return;
|
|
1927
|
+
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
1928
|
+
this.markDirty();
|
|
1929
|
+
});
|
|
1425
1930
|
}
|
|
1426
1931
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1427
1932
|
replayHistory() {
|
|
@@ -1447,7 +1952,7 @@ export class SshTui {
|
|
|
1447
1952
|
if (providerUsesLocalOAuth(provider)) {
|
|
1448
1953
|
this.pushRow({
|
|
1449
1954
|
kind: 'system',
|
|
1450
|
-
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}
|
|
1955
|
+
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;只有要改成 DeepSeek 官方或 OpenCode 这类 Key 提供商时才需要 /setup。`,
|
|
1451
1956
|
});
|
|
1452
1957
|
this.markDirty();
|
|
1453
1958
|
return;
|
|
@@ -1608,6 +2113,24 @@ export class SshTui {
|
|
|
1608
2113
|
process.exit(code);
|
|
1609
2114
|
}
|
|
1610
2115
|
// ── terminal output ─────────────────────────────────────────────────────
|
|
2116
|
+
/** Capture one painted frame. Used by README screenshot fixtures. */
|
|
2117
|
+
captureFrame(columns = 80, rows = 24) {
|
|
2118
|
+
const previousColumns = process.stdout.columns;
|
|
2119
|
+
const previousRows = process.stdout.rows;
|
|
2120
|
+
const previousWrite = this.write.bind(this);
|
|
2121
|
+
this.write = () => { };
|
|
2122
|
+
process.stdout.columns = columns;
|
|
2123
|
+
process.stdout.rows = rows;
|
|
2124
|
+
try {
|
|
2125
|
+
this.paint();
|
|
2126
|
+
return [...this.lastPaintRows];
|
|
2127
|
+
}
|
|
2128
|
+
finally {
|
|
2129
|
+
this.write = previousWrite;
|
|
2130
|
+
process.stdout.columns = previousColumns;
|
|
2131
|
+
process.stdout.rows = previousRows;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
1611
2134
|
write(chunk) {
|
|
1612
2135
|
process.stdout.write(chunk);
|
|
1613
2136
|
}
|
|
@@ -1648,24 +2171,137 @@ export class SshTui {
|
|
|
1648
2171
|
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
1649
2172
|
}
|
|
1650
2173
|
findLivePlanRow() {
|
|
1651
|
-
return this.rows.findLast((row) => row.kind === 'plan');
|
|
2174
|
+
return this.rows.findLast((row) => row.kind === 'plan' && planIsLive(row));
|
|
2175
|
+
}
|
|
2176
|
+
/** Older / finished plans stay in the scrolling transcript. */
|
|
2177
|
+
archiveStalePlans(keep) {
|
|
2178
|
+
for (const row of this.rows) {
|
|
2179
|
+
if (row.kind !== 'plan' || row === keep)
|
|
2180
|
+
continue;
|
|
2181
|
+
if (row.archived === true)
|
|
2182
|
+
continue;
|
|
2183
|
+
row.archived = true;
|
|
2184
|
+
row.active = false;
|
|
2185
|
+
row.pending = false;
|
|
2186
|
+
row.expanded = false;
|
|
2187
|
+
}
|
|
1652
2188
|
}
|
|
1653
2189
|
upsertPlanRow(patch) {
|
|
1654
2190
|
const existing = this.findLivePlanRow();
|
|
1655
|
-
|
|
2191
|
+
// A new docked plan only starts when the current one is no longer live
|
|
2192
|
+
// (completed / archived). Re-entering plan mode on the same incomplete
|
|
2193
|
+
// list must keep updating that row, not archive it.
|
|
2194
|
+
if (existing !== undefined && planIsLive(existing)) {
|
|
1656
2195
|
Object.assign(existing, patch);
|
|
2196
|
+
existing.archived = false;
|
|
2197
|
+
if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
|
|
2198
|
+
existing.turnLeftOpen = false;
|
|
2199
|
+
this.planNudgePending = false;
|
|
2200
|
+
}
|
|
2201
|
+
if (!planIsLive(existing)) {
|
|
2202
|
+
existing.archived = true;
|
|
2203
|
+
existing.expanded = false;
|
|
2204
|
+
}
|
|
2205
|
+
this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
|
|
1657
2206
|
return existing;
|
|
1658
2207
|
}
|
|
2208
|
+
if (existing !== undefined) {
|
|
2209
|
+
existing.archived = true;
|
|
2210
|
+
existing.active = false;
|
|
2211
|
+
existing.pending = false;
|
|
2212
|
+
existing.expanded = false;
|
|
2213
|
+
}
|
|
1659
2214
|
const row = {
|
|
1660
2215
|
kind: 'plan',
|
|
1661
2216
|
active: patch.active ?? false,
|
|
1662
2217
|
pending: patch.pending ?? false,
|
|
1663
2218
|
todos: patch.todos ?? [],
|
|
2219
|
+
...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
|
|
1664
2220
|
expanded: false,
|
|
2221
|
+
archived: false,
|
|
1665
2222
|
};
|
|
1666
2223
|
this.pushRow(row);
|
|
2224
|
+
this.archiveStalePlans(row);
|
|
1667
2225
|
return row;
|
|
1668
2226
|
}
|
|
2227
|
+
/** Whether the live plan strip should occupy the workspace footer. */
|
|
2228
|
+
shouldDockPlan() {
|
|
2229
|
+
return this.findLivePlanRow() !== undefined;
|
|
2230
|
+
}
|
|
2231
|
+
/** One follow-up per leftover list; replay and cancelled turns stay quiet. */
|
|
2232
|
+
queuePlanCloseNudge(plan) {
|
|
2233
|
+
if (this.replaying || this.agentGone || this.planNudgePending)
|
|
2234
|
+
return;
|
|
2235
|
+
if (this.agent.status === 'running')
|
|
2236
|
+
return;
|
|
2237
|
+
this.planNudgePending = true;
|
|
2238
|
+
const text = planCloseNudgeText(plan);
|
|
2239
|
+
this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
|
|
2240
|
+
const message = createUserMessage({
|
|
2241
|
+
content: [{ type: 'text', text }],
|
|
2242
|
+
source: { kind: 'user' },
|
|
2243
|
+
});
|
|
2244
|
+
try {
|
|
2245
|
+
this.agent.followup(message);
|
|
2246
|
+
}
|
|
2247
|
+
catch (error) {
|
|
2248
|
+
this.planNudgePending = false;
|
|
2249
|
+
this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
/** Compact web-style plan strip pinned above the input, not in the transcript. */
|
|
2253
|
+
paintPlanDock(width, yieldBottom) {
|
|
2254
|
+
const plan = this.findLivePlanRow();
|
|
2255
|
+
if (plan === undefined)
|
|
2256
|
+
return [];
|
|
2257
|
+
const inner = Math.max(1, width - 2);
|
|
2258
|
+
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
2259
|
+
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
2260
|
+
const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
|
|
2261
|
+
const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
|
|
2262
|
+
const mode = plan.pending ? '切换中'
|
|
2263
|
+
: leftOpen ? '本轮未收尾'
|
|
2264
|
+
: plan.active ? '计划模式'
|
|
2265
|
+
: running ? '计划'
|
|
2266
|
+
: allDone ? '计划完成'
|
|
2267
|
+
: '计划';
|
|
2268
|
+
const counts = todoProgressLabel(plan.todos);
|
|
2269
|
+
const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
|
|
2270
|
+
const summary = title ?? (counts === '' ? '还没有任务' : counts);
|
|
2271
|
+
const marker = plan.expanded ? '▾' : '▸';
|
|
2272
|
+
const focused = this.focusedRow === plan ? '▶ ' : ' ';
|
|
2273
|
+
const header = `${focused}${marker} ${mode}${spinner} · ${summary}${plan.expanded || yieldBottom ? '' : ' · Enter 展开'}`;
|
|
2274
|
+
const lines = [this.styleLine('plan-dock', padToWidth(header, width))];
|
|
2275
|
+
if (yieldBottom || !plan.expanded)
|
|
2276
|
+
return lines;
|
|
2277
|
+
const note = planDockNote(plan);
|
|
2278
|
+
lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
|
|
2279
|
+
if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
|
|
2280
|
+
const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
|
|
2281
|
+
const budget = Math.max(4, Math.min(12, markdown.length));
|
|
2282
|
+
for (const line of markdown.slice(0, budget)) {
|
|
2283
|
+
lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
|
|
2284
|
+
}
|
|
2285
|
+
if (markdown.length > budget) {
|
|
2286
|
+
lines.push(this.styleLine('plan-dock', padToWidth(` … 还有 ${markdown.length - budget} 行计划`, width)));
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (plan.todos.length === 0) {
|
|
2290
|
+
if (plan.planMarkdown === undefined || plan.planMarkdown === '') {
|
|
2291
|
+
lines.push(this.styleLine('todo-pending', padToWidth(' 还没有任务列表', width)));
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
else {
|
|
2295
|
+
for (const item of plan.todos) {
|
|
2296
|
+
const mark = TODO_STATUS_MARK[item.status];
|
|
2297
|
+
const kind = todoItemKind(item.status);
|
|
2298
|
+
for (const wrapped of wrap(`${mark} ${item.content}`, inner)) {
|
|
2299
|
+
lines.push(this.styleLine(kind, padToWidth(` ${wrapped}`, width)));
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return lines;
|
|
2304
|
+
}
|
|
1669
2305
|
paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
|
|
1670
2306
|
const focused = this.focusedRow === row;
|
|
1671
2307
|
const marker = row.expanded ? '▾' : '▸';
|
|
@@ -1727,6 +2363,87 @@ export class SshTui {
|
|
|
1727
2363
|
this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
|
|
1728
2364
|
this.markDirty();
|
|
1729
2365
|
}
|
|
2366
|
+
highlightSearchLine(line) {
|
|
2367
|
+
if (line.includes('\x1b[7m'))
|
|
2368
|
+
return line;
|
|
2369
|
+
return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
|
|
2370
|
+
}
|
|
2371
|
+
revealRow(row) {
|
|
2372
|
+
if (row === undefined)
|
|
2373
|
+
return;
|
|
2374
|
+
if (row.kind !== 'assistant' && 'expanded' in row) {
|
|
2375
|
+
row.expanded = true;
|
|
2376
|
+
this.focusedRow = row;
|
|
2377
|
+
}
|
|
2378
|
+
else {
|
|
2379
|
+
this.focusedRow = null;
|
|
2380
|
+
}
|
|
2381
|
+
this.pendingReveal = row;
|
|
2382
|
+
this.markDirty();
|
|
2383
|
+
}
|
|
2384
|
+
focusCard(row) {
|
|
2385
|
+
this.revealRow(row);
|
|
2386
|
+
}
|
|
2387
|
+
/** Jump to the newest card in a category (thinking / plan / subagent / reply). */
|
|
2388
|
+
jumpToCategory(category) {
|
|
2389
|
+
if (category === 'plan') {
|
|
2390
|
+
const live = this.findLivePlanRow();
|
|
2391
|
+
if (live !== undefined) {
|
|
2392
|
+
this.focusCard(live);
|
|
2393
|
+
this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
|
|
2394
|
+
this.revealRow(live);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
const target = this.rows.findLast(row => cardCategoryOf(row) === category);
|
|
2399
|
+
if (target === undefined) {
|
|
2400
|
+
this.pushRow({ kind: 'system', text: `当前没有${CARD_CATEGORY_LABEL[category]}卡片。` });
|
|
2401
|
+
this.markDirty();
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
|
|
2405
|
+
this.revealRow(target);
|
|
2406
|
+
}
|
|
2407
|
+
applySearchHits(query, hits) {
|
|
2408
|
+
this.searchQuery = query;
|
|
2409
|
+
this.searchHits = hits;
|
|
2410
|
+
if (hits.length === 0) {
|
|
2411
|
+
this.searchIndex = -1;
|
|
2412
|
+
this.pushRow({ kind: 'system', text: query === '' ? '没有可搜索的卡片。' : `没有匹配「${query}」的卡片。` });
|
|
2413
|
+
this.markDirty();
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
this.searchIndex = hits.length - 1;
|
|
2417
|
+
const hit = hits[this.searchIndex];
|
|
2418
|
+
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2419
|
+
this.pushRow({
|
|
2420
|
+
kind: 'system',
|
|
2421
|
+
text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
|
|
2422
|
+
});
|
|
2423
|
+
this.revealRow(hit);
|
|
2424
|
+
}
|
|
2425
|
+
runFindCommand(arg) {
|
|
2426
|
+
const parsed = parseFindQuery(arg);
|
|
2427
|
+
const label = parsed.category === undefined ? '' : `${CARD_CATEGORY_LABEL[parsed.category]} `;
|
|
2428
|
+
const hits = matchTranscriptRows(this.rows, arg);
|
|
2429
|
+
this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
|
|
2430
|
+
}
|
|
2431
|
+
stepSearch(delta) {
|
|
2432
|
+
if (this.searchHits.length === 0) {
|
|
2433
|
+
this.pushRow({ kind: 'system', text: '还没有搜索结果。用 /find 思考 padAnsi,或 Ctrl+/ 打开搜索。' });
|
|
2434
|
+
this.markDirty();
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const count = this.searchHits.length;
|
|
2438
|
+
this.searchIndex = (this.searchIndex + delta + count) % count;
|
|
2439
|
+
const hit = this.searchHits[this.searchIndex];
|
|
2440
|
+
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2441
|
+
this.pushRow({
|
|
2442
|
+
kind: 'system',
|
|
2443
|
+
text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
|
|
2444
|
+
});
|
|
2445
|
+
this.revealRow(hit);
|
|
2446
|
+
}
|
|
1730
2447
|
paint = () => {
|
|
1731
2448
|
if (this.exiting)
|
|
1732
2449
|
return;
|
|
@@ -1734,19 +2451,21 @@ export class SshTui {
|
|
|
1734
2451
|
const height = Math.max(6, process.stdout.rows || 24);
|
|
1735
2452
|
const display = [];
|
|
1736
2453
|
const displayRefs = [];
|
|
2454
|
+
const searchHit = this.searchHits[this.searchIndex];
|
|
1737
2455
|
const addDisplay = (line, ref) => {
|
|
1738
|
-
|
|
2456
|
+
const hit = ref !== undefined && ref === searchHit;
|
|
2457
|
+
display.push(hit ? this.highlightSearchLine(line) : line);
|
|
1739
2458
|
displayRefs.push(ref);
|
|
1740
2459
|
};
|
|
1741
|
-
const pushRow = (kind, text) => {
|
|
2460
|
+
const pushRow = (kind, text, ref) => {
|
|
1742
2461
|
if (kind === 'assistant') {
|
|
1743
2462
|
for (const line of renderMarkdownLines(text, width, this.color)) {
|
|
1744
|
-
addDisplay(line);
|
|
2463
|
+
addDisplay(line, ref);
|
|
1745
2464
|
}
|
|
1746
2465
|
return;
|
|
1747
2466
|
}
|
|
1748
2467
|
for (const line of wrap(text, width)) {
|
|
1749
|
-
addDisplay(this.styleLine(kind, line));
|
|
2468
|
+
addDisplay(this.styleLine(kind, line), ref);
|
|
1750
2469
|
}
|
|
1751
2470
|
};
|
|
1752
2471
|
for (const row of this.rows) {
|
|
@@ -1766,13 +2485,13 @@ export class SshTui {
|
|
|
1766
2485
|
const focused = this.focusedRow === row;
|
|
1767
2486
|
const marker = row.expanded ? '▾' : '▸';
|
|
1768
2487
|
const lines = row.text.split('\n').length;
|
|
1769
|
-
const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' ·
|
|
2488
|
+
const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1770
2489
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
1771
2490
|
const styled = this.styleLine('reasoning', line);
|
|
1772
2491
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
1773
2492
|
if (row.expanded) {
|
|
1774
2493
|
for (const wrapped of wrap(row.text, width)) {
|
|
1775
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2494
|
+
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
1776
2495
|
}
|
|
1777
2496
|
}
|
|
1778
2497
|
continue;
|
|
@@ -1785,10 +2504,13 @@ export class SshTui {
|
|
|
1785
2504
|
// "[33m" text on screen; color the dot between two sanitized halves.
|
|
1786
2505
|
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1787
2506
|
const styleToolHeader = (line) => {
|
|
1788
|
-
const
|
|
2507
|
+
const safe = sanitizeTerminalText(line);
|
|
2508
|
+
if (!this.color)
|
|
2509
|
+
return safe;
|
|
2510
|
+
const dotIndex = safe.indexOf('●');
|
|
1789
2511
|
if (dotColor === undefined || dotIndex === -1)
|
|
1790
|
-
return this.styleLine('tool',
|
|
1791
|
-
return
|
|
2512
|
+
return this.styleLine('tool', safe);
|
|
2513
|
+
return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
|
|
1792
2514
|
};
|
|
1793
2515
|
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
1794
2516
|
const state = running ? 'running…' : ok ? 'ok' : 'error';
|
|
@@ -1813,8 +2535,11 @@ export class SshTui {
|
|
|
1813
2535
|
addDisplay(styleToolHeader(wrapped), row);
|
|
1814
2536
|
}
|
|
1815
2537
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
1816
|
-
|
|
1817
|
-
|
|
2538
|
+
const inner = Math.max(1, width - 2);
|
|
2539
|
+
const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
|
|
2540
|
+
for (const wrapped of wrap(line.text, inner)) {
|
|
2541
|
+
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2542
|
+
addDisplay(this.styleLine(line.kind, body), row);
|
|
1818
2543
|
}
|
|
1819
2544
|
}
|
|
1820
2545
|
continue;
|
|
@@ -1824,21 +2549,24 @@ export class SshTui {
|
|
|
1824
2549
|
const ok = row.status === 'ok';
|
|
1825
2550
|
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1826
2551
|
const styleHeader = (line) => {
|
|
1827
|
-
const
|
|
2552
|
+
const safe = sanitizeTerminalText(line);
|
|
2553
|
+
if (!this.color)
|
|
2554
|
+
return safe;
|
|
2555
|
+
const dotIndex = safe.indexOf('●');
|
|
1828
2556
|
if (dotColor === undefined || dotIndex === -1)
|
|
1829
|
-
return this.styleLine('tool',
|
|
1830
|
-
return
|
|
2557
|
+
return this.styleLine('tool', safe);
|
|
2558
|
+
return `\x1b[33m${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[33m${safe.slice(dotIndex + 1)}\x1b[0m`;
|
|
1831
2559
|
};
|
|
1832
2560
|
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
1833
2561
|
const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1834
2562
|
this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
|
|
1835
2563
|
if (row.expanded) {
|
|
1836
|
-
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
|
|
2564
|
+
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
|
|
1837
2565
|
if (row.stopReason !== undefined) {
|
|
1838
|
-
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
|
|
2566
|
+
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
|
|
1839
2567
|
}
|
|
1840
2568
|
if (row.logs.length === 0) {
|
|
1841
|
-
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
|
|
2569
|
+
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
|
|
1842
2570
|
}
|
|
1843
2571
|
else {
|
|
1844
2572
|
for (const entry of row.logs) {
|
|
@@ -1848,7 +2576,7 @@ export class SshTui {
|
|
|
1848
2576
|
? 'error'
|
|
1849
2577
|
: 'tool-result';
|
|
1850
2578
|
for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
|
|
1851
|
-
addDisplay(this.styleLine(kind, ` ${wrapped}`));
|
|
2579
|
+
addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
|
|
1852
2580
|
}
|
|
1853
2581
|
}
|
|
1854
2582
|
}
|
|
@@ -1856,30 +2584,24 @@ export class SshTui {
|
|
|
1856
2584
|
continue;
|
|
1857
2585
|
}
|
|
1858
2586
|
if (row.kind === 'plan') {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
const header = `● ${mode}${spinner} · ${todoSummary(row.todos)}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1867
|
-
this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
|
|
2587
|
+
if (planIsLive(row) && this.findLivePlanRow() === row)
|
|
2588
|
+
continue;
|
|
2589
|
+
const counts = todoProgressLabel(row.todos);
|
|
2590
|
+
const title = planTitleFromMarkdown(row.planMarkdown ?? '');
|
|
2591
|
+
const summary = title ?? (counts === '' ? '已归档' : counts);
|
|
2592
|
+
const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2593
|
+
this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
|
|
1868
2594
|
if (row.expanded) {
|
|
1869
|
-
addDisplay(this.styleLine('
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
if (row.todos.length === 0) {
|
|
1875
|
-
addDisplay(this.styleLine('tool-result', ' 还没有任务列表'));
|
|
2595
|
+
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
|
|
2596
|
+
if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
|
|
2597
|
+
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
|
|
2598
|
+
addDisplay(` ${line}`, row);
|
|
2599
|
+
}
|
|
1876
2600
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
addDisplay(this.styleLine(item.status === 'completed' ? 'system' : 'tool', ` ${wrapped}`));
|
|
1882
|
-
}
|
|
2601
|
+
for (const item of row.todos) {
|
|
2602
|
+
const mark = TODO_STATUS_MARK[item.status];
|
|
2603
|
+
for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
|
|
2604
|
+
addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`), row);
|
|
1883
2605
|
}
|
|
1884
2606
|
}
|
|
1885
2607
|
}
|
|
@@ -1894,18 +2616,25 @@ export class SshTui {
|
|
|
1894
2616
|
this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
|
|
1895
2617
|
if (row.expanded) {
|
|
1896
2618
|
if (row.header !== undefined)
|
|
1897
|
-
addDisplay(this.styleLine('system', ` ${row.header}`));
|
|
2619
|
+
addDisplay(this.styleLine('system', ` ${row.header}`), row);
|
|
1898
2620
|
for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
|
|
1899
|
-
addDisplay(this.styleLine('assistant', ` ${wrapped}`));
|
|
2621
|
+
addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
|
|
1900
2622
|
}
|
|
1901
2623
|
if (row.detail !== undefined && row.detail !== '') {
|
|
1902
|
-
|
|
1903
|
-
|
|
2624
|
+
if (row.intent === 'plan-review') {
|
|
2625
|
+
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
|
|
2626
|
+
addDisplay(` ${line}`, row);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
else {
|
|
2630
|
+
for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
|
|
2631
|
+
addDisplay(this.styleLine('tool-result', ` ${wrapped}`), row);
|
|
2632
|
+
}
|
|
1904
2633
|
}
|
|
1905
2634
|
}
|
|
1906
2635
|
addDisplay(this.styleLine('system', waiting
|
|
1907
2636
|
? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
|
|
1908
|
-
: ` ${row.summary}`));
|
|
2637
|
+
: ` ${row.summary}`), row);
|
|
1909
2638
|
}
|
|
1910
2639
|
continue;
|
|
1911
2640
|
}
|
|
@@ -1920,16 +2649,16 @@ export class SshTui {
|
|
|
1920
2649
|
const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1921
2650
|
this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
|
|
1922
2651
|
if (row.expanded) {
|
|
1923
|
-
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
|
|
2652
|
+
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
|
|
1924
2653
|
if (row.blockedReason !== undefined) {
|
|
1925
2654
|
for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
|
|
1926
|
-
addDisplay(this.styleLine('error', ` ${wrapped}`));
|
|
2655
|
+
addDisplay(this.styleLine('error', ` ${wrapped}`), row);
|
|
1927
2656
|
}
|
|
1928
2657
|
}
|
|
1929
2658
|
}
|
|
1930
2659
|
continue;
|
|
1931
2660
|
}
|
|
1932
|
-
pushRow(row.kind, row.text);
|
|
2661
|
+
pushRow(row.kind, row.text, row);
|
|
1933
2662
|
}
|
|
1934
2663
|
if (this.streaming !== undefined) {
|
|
1935
2664
|
if (this.showReasoning && this.streaming.reasoning !== '') {
|
|
@@ -1947,7 +2676,7 @@ export class SshTui {
|
|
|
1947
2676
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
|
|
1948
2677
|
if (block.expanded) {
|
|
1949
2678
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
1950
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2679
|
+
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
1951
2680
|
}
|
|
1952
2681
|
}
|
|
1953
2682
|
}
|
|
@@ -2035,7 +2764,9 @@ export class SshTui {
|
|
|
2035
2764
|
addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
|
|
2036
2765
|
addDialog(d.question.question);
|
|
2037
2766
|
if (d.question.detail !== undefined && d.question.detail !== '') {
|
|
2038
|
-
|
|
2767
|
+
for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
|
|
2768
|
+
dialogLines.push(this.styleLine('assistant', line));
|
|
2769
|
+
}
|
|
2039
2770
|
}
|
|
2040
2771
|
}
|
|
2041
2772
|
else {
|
|
@@ -2064,12 +2795,11 @@ export class SshTui {
|
|
|
2064
2795
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
2065
2796
|
const headerLines = [
|
|
2066
2797
|
this.styleLine('system', fitLine(`DeepSeek Harness — SSH TUI [${this.presetName}] ${this.currentSelectionLabel()}`)),
|
|
2067
|
-
this.styleLine('system', '─'
|
|
2798
|
+
this.styleLine('system', repeatToWidth('─', width)),
|
|
2068
2799
|
];
|
|
2069
2800
|
if (this.scrollOffset > 0) {
|
|
2070
2801
|
headerLines.push(this.styleLine('system', fitLine(`↑ 已回看 ${this.scrollOffset} 行 · PgUp/PgDn/滚轮滚动 · Esc 回到底部`)));
|
|
2071
2802
|
}
|
|
2072
|
-
const inputDivider = this.styleLine('system', '─'.repeat(width));
|
|
2073
2803
|
this.commandSuggestions = this.dialog === undefined ? this.buildSuggestions() : [];
|
|
2074
2804
|
if (this.suggestionIndex >= this.commandSuggestions.length) {
|
|
2075
2805
|
this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
|
|
@@ -2087,7 +2817,7 @@ export class SshTui {
|
|
|
2087
2817
|
const promptWidth = displayWidth(promptPlain);
|
|
2088
2818
|
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
2089
2819
|
const inputView = masked
|
|
2090
|
-
? { text: '•'.repeat(this.input.length), cursorOffset: this.cursor, folded: false }
|
|
2820
|
+
? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
|
|
2091
2821
|
: this.inputFolded
|
|
2092
2822
|
? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
|
|
2093
2823
|
: { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
|
|
@@ -2132,9 +2862,26 @@ export class SshTui {
|
|
|
2132
2862
|
}
|
|
2133
2863
|
}
|
|
2134
2864
|
const inputRows = Math.max(1, inputDisplayLines.length);
|
|
2135
|
-
const
|
|
2865
|
+
const yieldPlanDock = this.dialog !== undefined || suggestionLines.length > 0;
|
|
2866
|
+
const planDockLines = this.shouldDockPlan()
|
|
2867
|
+
? this.paintPlanDock(width, yieldPlanDock)
|
|
2868
|
+
: [];
|
|
2869
|
+
const inputDivider = this.styleLine('system', repeatToWidth('─', width));
|
|
2870
|
+
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
2136
2871
|
const available = Math.max(1, height - reserved - dialogLines.length);
|
|
2137
2872
|
const maxOffset = Math.max(0, display.length - available);
|
|
2873
|
+
const reveal = this.pendingReveal;
|
|
2874
|
+
if (reveal !== undefined) {
|
|
2875
|
+
this.pendingReveal = undefined;
|
|
2876
|
+
const first = displayRefs.findIndex(ref => ref === reveal);
|
|
2877
|
+
if (first !== -1) {
|
|
2878
|
+
let last = first;
|
|
2879
|
+
while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
|
|
2880
|
+
last += 1;
|
|
2881
|
+
const span = last - first + 1;
|
|
2882
|
+
this.scrollOffset = Math.max(0, display.length - available - first);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2138
2885
|
if (this.scrollOffset > maxOffset)
|
|
2139
2886
|
this.scrollOffset = maxOffset;
|
|
2140
2887
|
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
@@ -2148,9 +2895,14 @@ export class SshTui {
|
|
|
2148
2895
|
this.clickableRows.clear();
|
|
2149
2896
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
2150
2897
|
const ref = visibleRefs[index];
|
|
2151
|
-
if (ref !== undefined)
|
|
2898
|
+
if (ref !== undefined && 'expanded' in ref)
|
|
2152
2899
|
this.clickableRows.set(headerLines.length + index + 1, ref);
|
|
2153
2900
|
}
|
|
2901
|
+
const dockPlan = this.findLivePlanRow();
|
|
2902
|
+
if (dockPlan !== undefined && planDockLines.length > 0) {
|
|
2903
|
+
const dockTop = headerLines.length + visible.length + 1;
|
|
2904
|
+
this.clickableRows.set(dockTop, dockPlan);
|
|
2905
|
+
}
|
|
2154
2906
|
const statsText = this.statsText();
|
|
2155
2907
|
const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
|
|
2156
2908
|
let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
|
|
@@ -2160,6 +2912,9 @@ export class SshTui {
|
|
|
2160
2912
|
statusText += sub.provider === undefined
|
|
2161
2913
|
? ` · sub:${sub.model}${subEffort}`
|
|
2162
2914
|
: ` · sub:${subProvider}/${sub.model}${subEffort}`;
|
|
2915
|
+
if (this.searchHits.length > 0 && this.searchIndex >= 0) {
|
|
2916
|
+
statusText += ` · 搜索 ${this.searchIndex + 1}/${this.searchHits.length}`;
|
|
2917
|
+
}
|
|
2163
2918
|
if (inputView.folded)
|
|
2164
2919
|
statusText += ' · 输入已折叠 · Ctrl+T 展开';
|
|
2165
2920
|
else if (inputRows > 1)
|
|
@@ -2174,6 +2929,9 @@ export class SshTui {
|
|
|
2174
2929
|
? ' · 计划待审'
|
|
2175
2930
|
: ' · 等待用户回答';
|
|
2176
2931
|
}
|
|
2932
|
+
else if (livePlan?.turnLeftOpen === true) {
|
|
2933
|
+
statusText += ' · 本轮未收尾';
|
|
2934
|
+
}
|
|
2177
2935
|
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
2178
2936
|
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
2179
2937
|
}
|
|
@@ -2196,6 +2954,7 @@ export class SshTui {
|
|
|
2196
2954
|
const paintRows = [
|
|
2197
2955
|
...headerLines,
|
|
2198
2956
|
...visible,
|
|
2957
|
+
...planDockLines,
|
|
2199
2958
|
...dialogLines,
|
|
2200
2959
|
inputDivider,
|
|
2201
2960
|
...suggestionLines,
|
|
@@ -2206,7 +2965,7 @@ export class SshTui {
|
|
|
2206
2965
|
// Bottom chrome is force-repainted whenever its state changes while the
|
|
2207
2966
|
// agent is working; this clears any stale cell left behind by a previous
|
|
2208
2967
|
// frame even when the row strings happen to be identical.
|
|
2209
|
-
const chromeStart = Math.max(0, paintRows.length - inputRows - 3);
|
|
2968
|
+
const chromeStart = Math.max(0, paintRows.length - inputRows - suggestionLines.length - dialogLines.length - planDockLines.length - 3);
|
|
2210
2969
|
const chromeKey = [
|
|
2211
2970
|
this.status,
|
|
2212
2971
|
this.agent.status,
|
|
@@ -2217,33 +2976,37 @@ export class SshTui {
|
|
|
2217
2976
|
inputView.folded,
|
|
2218
2977
|
inputRows,
|
|
2219
2978
|
paintRows.length,
|
|
2979
|
+
width,
|
|
2980
|
+
height,
|
|
2220
2981
|
this.pendingMessages.size,
|
|
2221
2982
|
this.commandSuggestions.length,
|
|
2222
2983
|
this.suggestionIndex,
|
|
2223
2984
|
this.activeSubagents.size,
|
|
2224
2985
|
this.dialog?.kind ?? '',
|
|
2225
|
-
|
|
2986
|
+
planDockLines.join('\n'),
|
|
2226
2987
|
].join('\x1f');
|
|
2227
2988
|
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
2228
|
-
|
|
2229
|
-
//
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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;
|
|
2989
|
+
const sizeChanged = width !== this.lastPaintWidth || height !== this.lastPaintHeight;
|
|
2990
|
+
// One stdout write per frame: dirty rows only, so jump-host SSH sees a
|
|
2991
|
+
// single packet instead of one write per line. Clip/pad so leftover
|
|
2992
|
+
// wide glyphs cannot wrap into the input box.
|
|
2993
|
+
const inputTopRow = visible.length + planDockLines.length + dialogLines.length + suggestionLines.length + headerLines.length + 2;
|
|
2245
2994
|
const row = Math.min(height, inputTopRow + cursorRowOffset);
|
|
2246
|
-
this.write(
|
|
2995
|
+
this.write(composePaintOutput({
|
|
2996
|
+
width,
|
|
2997
|
+
height,
|
|
2998
|
+
paintRows,
|
|
2999
|
+
previousRows: this.lastPaintRows,
|
|
3000
|
+
sizeChanged,
|
|
3001
|
+
chromeChanged,
|
|
3002
|
+
chromeStart,
|
|
3003
|
+
cursorRow: row,
|
|
3004
|
+
cursorColumn: column,
|
|
3005
|
+
}));
|
|
3006
|
+
this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
|
|
3007
|
+
this.lastChromeKey = chromeKey;
|
|
3008
|
+
this.lastPaintWidth = width;
|
|
3009
|
+
this.lastPaintHeight = height;
|
|
2247
3010
|
};
|
|
2248
3011
|
buildSuggestions() {
|
|
2249
3012
|
const input = this.input;
|
|
@@ -2405,9 +3168,13 @@ export class SshTui {
|
|
|
2405
3168
|
kind === 'diff-add' ? '38;5;22;48;5;194' :
|
|
2406
3169
|
kind === 'diff-del' ? '38;5;124;48;5;224' :
|
|
2407
3170
|
kind === 'diff-path' ? '1;36' :
|
|
2408
|
-
kind === '
|
|
2409
|
-
'
|
|
2410
|
-
|
|
3171
|
+
kind === 'todo-done' ? '2;32' :
|
|
3172
|
+
kind === 'todo-active' ? '1;36' :
|
|
3173
|
+
kind === 'todo-pending' ? '90' :
|
|
3174
|
+
kind === 'plan-dock' ? '38;5;180' :
|
|
3175
|
+
kind === 'error' ? '31' :
|
|
3176
|
+
'90';
|
|
3177
|
+
return `\x1b[${code}m${safe}\x1b[0m`;
|
|
2411
3178
|
}
|
|
2412
3179
|
// ── event handling ──────────────────────────────────────────────────────
|
|
2413
3180
|
/**
|
|
@@ -2421,10 +3188,15 @@ export class SshTui {
|
|
|
2421
3188
|
if (agent === this.agent)
|
|
2422
3189
|
return resolved;
|
|
2423
3190
|
const selection = this.subagentSelection.current;
|
|
3191
|
+
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
3192
|
+
const provider = selection.provider ?? parentProvider;
|
|
3193
|
+
const model = subagentModelMatchesProvider(provider, selection.model)
|
|
3194
|
+
? selection.model
|
|
3195
|
+
: defaultSubagentModelForProvider(provider);
|
|
2424
3196
|
return {
|
|
2425
3197
|
...resolved,
|
|
2426
|
-
|
|
2427
|
-
model
|
|
3198
|
+
provider,
|
|
3199
|
+
model,
|
|
2428
3200
|
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
|
|
2429
3201
|
};
|
|
2430
3202
|
};
|
|
@@ -2551,6 +3323,11 @@ export class SshTui {
|
|
|
2551
3323
|
expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
|
|
2552
3324
|
};
|
|
2553
3325
|
this.pushRow(row);
|
|
3326
|
+
if (event.data.name === 'exit_plan_mode') {
|
|
3327
|
+
const markdown = planMarkdownFromArgs(event.data.arguments);
|
|
3328
|
+
if (markdown !== undefined)
|
|
3329
|
+
this.upsertPlanRow({ planMarkdown: markdown, expanded: false });
|
|
3330
|
+
}
|
|
2554
3331
|
this.streaming = undefined;
|
|
2555
3332
|
this.markDirty();
|
|
2556
3333
|
break;
|
|
@@ -2658,6 +3435,17 @@ export class SshTui {
|
|
|
2658
3435
|
if (reason.kind === 'error') {
|
|
2659
3436
|
this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
|
|
2660
3437
|
}
|
|
3438
|
+
const livePlan = this.findLivePlanRow();
|
|
3439
|
+
if (livePlan !== undefined && reason.kind === 'completed') {
|
|
3440
|
+
applyTurnEndToPlan(livePlan);
|
|
3441
|
+
if (livePlan.turnLeftOpen === true) {
|
|
3442
|
+
this.pushRow({
|
|
3443
|
+
kind: 'system',
|
|
3444
|
+
text: planDockNote(livePlan),
|
|
3445
|
+
});
|
|
3446
|
+
this.queuePlanCloseNudge(livePlan);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
2661
3449
|
this.markDirty();
|
|
2662
3450
|
break;
|
|
2663
3451
|
}
|
|
@@ -3096,13 +3884,13 @@ export class SshTui {
|
|
|
3096
3884
|
this.removeQueuedDialog(dialog);
|
|
3097
3885
|
dialog.resolve('cancel');
|
|
3098
3886
|
}
|
|
3099
|
-
openQuestion(question, index, total, resolve, reject) {
|
|
3887
|
+
openQuestion(question, index, total, resolve, reject, preselected) {
|
|
3100
3888
|
const dialog = {
|
|
3101
3889
|
kind: 'questions',
|
|
3102
3890
|
question,
|
|
3103
3891
|
index,
|
|
3104
3892
|
total,
|
|
3105
|
-
selected: new Set(),
|
|
3893
|
+
selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
|
|
3106
3894
|
resolve: (selection) => {
|
|
3107
3895
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
3108
3896
|
},
|
|
@@ -3114,9 +3902,9 @@ export class SshTui {
|
|
|
3114
3902
|
return dialog;
|
|
3115
3903
|
}
|
|
3116
3904
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
3117
|
-
askQuestion(question, index = 0, total = 1) {
|
|
3905
|
+
askQuestion(question, index = 0, total = 1, preselected) {
|
|
3118
3906
|
return new Promise((resolve, reject) => {
|
|
3119
|
-
this.openQuestion(question, index, total, resolve, reject);
|
|
3907
|
+
this.openQuestion(question, index, total, resolve, reject, preselected);
|
|
3120
3908
|
});
|
|
3121
3909
|
}
|
|
3122
3910
|
/** The stored llm-pi-ai profile for one provider route, when settings provide one. */
|
|
@@ -3238,11 +4026,12 @@ export class SshTui {
|
|
|
3238
4026
|
options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
|
|
3239
4027
|
if (hasNext)
|
|
3240
4028
|
options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
|
|
4029
|
+
const currentIndex = page.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
|
|
3241
4030
|
const answer = await this.askQuestion({
|
|
3242
4031
|
id: 'model-pick',
|
|
3243
4032
|
question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
|
|
3244
4033
|
options,
|
|
3245
|
-
});
|
|
4034
|
+
}, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
|
|
3246
4035
|
const picked = options.find(option => option.label === answer.selected[0]);
|
|
3247
4036
|
if (picked === undefined)
|
|
3248
4037
|
return undefined;
|
|
@@ -3271,23 +4060,33 @@ export class SshTui {
|
|
|
3271
4060
|
const display = name !== undefined && name !== '' && name !== id ? name : kind.short;
|
|
3272
4061
|
out.push({ id, label: `${display} · ${id}` });
|
|
3273
4062
|
};
|
|
4063
|
+
add(current);
|
|
3274
4064
|
for (const info of llm?.listProviders() ?? [])
|
|
3275
4065
|
add(info.id, info.name);
|
|
3276
|
-
add(current);
|
|
3277
|
-
add('deepseek-official', 'DeepSeek 官方');
|
|
3278
4066
|
add('xai', 'SuperGrok');
|
|
4067
|
+
add('deepseek-official', 'DeepSeek 官方');
|
|
3279
4068
|
add('opencode-go', 'OpenCode Go');
|
|
3280
4069
|
add('opencode', 'OpenCode Zen');
|
|
3281
4070
|
return out;
|
|
3282
4071
|
}
|
|
3283
|
-
/**
|
|
4072
|
+
/** Built-in SuperGrok catalog used when the live adapter list is still warming up. */
|
|
4073
|
+
static XAI_FALLBACK_MODELS = [
|
|
4074
|
+
{ id: 'grok-4.6', label: 'Grok 4.6' },
|
|
4075
|
+
{ id: 'grok-4.5', label: 'Grok 4.5' },
|
|
4076
|
+
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
4077
|
+
];
|
|
4078
|
+
/** /model: stay on the current provider by default; switching providers is opt-in. */
|
|
3284
4079
|
async runModelCommand() {
|
|
3285
4080
|
const llm = this.ctx.get('llm');
|
|
3286
4081
|
const current = this.selectionRef?.current;
|
|
3287
4082
|
const providers = this.listSelectableProviders();
|
|
3288
4083
|
let provider = this.currentProviderId();
|
|
3289
|
-
|
|
3290
|
-
|
|
4084
|
+
const SWITCH_PROVIDER_ID = '__switch_provider__';
|
|
4085
|
+
const pickProvider = async () => {
|
|
4086
|
+
if (providers.length <= 1)
|
|
4087
|
+
return provider;
|
|
4088
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
4089
|
+
const pickedAnswer = await this.askQuestion({
|
|
3291
4090
|
id: 'provider-pick',
|
|
3292
4091
|
question: '选择提供商',
|
|
3293
4092
|
options: providers.map(option => ({
|
|
@@ -3296,12 +4095,9 @@ export class SshTui {
|
|
|
3296
4095
|
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
3297
4096
|
: describeProviderRoute(option.id).kind,
|
|
3298
4097
|
})),
|
|
3299
|
-
});
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
return;
|
|
3303
|
-
provider = picked.id;
|
|
3304
|
-
}
|
|
4098
|
+
}, 0, 1, currentIndex);
|
|
4099
|
+
return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4100
|
+
};
|
|
3305
4101
|
let modelOptions = [];
|
|
3306
4102
|
let modelSource = '已配置列表';
|
|
3307
4103
|
// OpenCode and other third-party routes are interrogated live so the picker
|
|
@@ -3348,42 +4144,115 @@ export class SshTui {
|
|
|
3348
4144
|
modelOptions = [];
|
|
3349
4145
|
}
|
|
3350
4146
|
}
|
|
4147
|
+
if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4148
|
+
modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4149
|
+
modelSource = 'SuperGrok 目录';
|
|
4150
|
+
}
|
|
3351
4151
|
if (modelOptions.length === 0) {
|
|
3352
|
-
const fallback = current?.model ?? this.agent.options.model ?? 'deepseek-v4-flash';
|
|
4152
|
+
const fallback = current?.model ?? this.agent.options.model ?? (providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash');
|
|
3353
4153
|
modelOptions = [{ id: fallback, label: fallback }];
|
|
3354
4154
|
}
|
|
4155
|
+
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
4156
|
+
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
4157
|
+
}
|
|
4158
|
+
if (providers.length > 1) {
|
|
4159
|
+
modelOptions = [
|
|
4160
|
+
...modelOptions,
|
|
4161
|
+
{ id: SWITCH_PROVIDER_ID, label: '更换提供商…' },
|
|
4162
|
+
];
|
|
4163
|
+
}
|
|
3355
4164
|
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
3356
4165
|
if (selected === undefined)
|
|
3357
4166
|
return;
|
|
3358
|
-
if (
|
|
4167
|
+
if (selected.id === SWITCH_PROVIDER_ID) {
|
|
4168
|
+
const nextProvider = await pickProvider();
|
|
4169
|
+
if (nextProvider === undefined || nextProvider === provider)
|
|
4170
|
+
return;
|
|
4171
|
+
provider = nextProvider;
|
|
4172
|
+
modelOptions = [];
|
|
4173
|
+
modelSource = '已配置列表';
|
|
4174
|
+
// Reload the model list for the newly chosen provider.
|
|
4175
|
+
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
4176
|
+
try {
|
|
4177
|
+
modelOptions = await this.discoverEndpointModels(provider);
|
|
4178
|
+
if (modelOptions.length > 0)
|
|
4179
|
+
modelSource = '端点实时列表';
|
|
4180
|
+
}
|
|
4181
|
+
catch {
|
|
4182
|
+
modelOptions = [];
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
if (modelOptions.length === 0) {
|
|
4186
|
+
try {
|
|
4187
|
+
const listed = (await llm?.listModels(provider)) ?? [];
|
|
4188
|
+
modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
4189
|
+
}
|
|
4190
|
+
catch {
|
|
4191
|
+
modelOptions = [];
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4195
|
+
modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4196
|
+
modelSource = 'SuperGrok 目录';
|
|
4197
|
+
}
|
|
4198
|
+
if (modelOptions.length === 0) {
|
|
4199
|
+
const fallback = providerUsesLocalOAuth(provider) ? 'grok-4.6' : 'deepseek-v4-flash';
|
|
4200
|
+
modelOptions = [{ id: fallback, label: fallback }];
|
|
4201
|
+
}
|
|
4202
|
+
const switched = await this.pickModelOption(modelOptions, provider, modelSource, undefined);
|
|
4203
|
+
if (switched === undefined)
|
|
4204
|
+
return;
|
|
4205
|
+
return await this.applyModelSelection(provider, switched.id, undefined);
|
|
4206
|
+
}
|
|
4207
|
+
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id).filter(id => id !== SWITCH_PROVIDER_ID));
|
|
4208
|
+
}
|
|
4209
|
+
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
4210
|
+
async applyModelSelection(provider, modelId, listed = []) {
|
|
4211
|
+
if (!(await this.ensureProviderModelConfigured(provider, modelId)))
|
|
3359
4212
|
return;
|
|
4213
|
+
const llm = this.ctx.get('llm');
|
|
4214
|
+
const current = this.selectionRef?.current;
|
|
3360
4215
|
let effortOptions = [];
|
|
3361
4216
|
try {
|
|
3362
|
-
const info = await llm?.resolveModelInfo(provider,
|
|
4217
|
+
const info = await llm?.resolveModelInfo(provider, modelId);
|
|
3363
4218
|
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
3364
4219
|
}
|
|
3365
4220
|
catch {
|
|
3366
4221
|
effortOptions = [];
|
|
3367
4222
|
}
|
|
3368
|
-
if (effortOptions.length === 0) {
|
|
3369
|
-
|
|
3370
|
-
|
|
4223
|
+
if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4224
|
+
effortOptions = modelId === 'grok-4.6'
|
|
4225
|
+
? [
|
|
4226
|
+
{ id: 'off', label: 'Off' },
|
|
4227
|
+
{ id: 'low', label: 'Low' },
|
|
4228
|
+
{ id: 'medium', label: 'Medium' },
|
|
4229
|
+
{ id: 'high', label: 'High' },
|
|
4230
|
+
{ id: 'xhigh', label: 'Extra high' },
|
|
4231
|
+
]
|
|
4232
|
+
: [
|
|
4233
|
+
{ id: 'off', label: 'Off' },
|
|
4234
|
+
{ id: 'low', label: 'Low' },
|
|
4235
|
+
{ id: 'medium', label: 'Medium' },
|
|
4236
|
+
{ id: 'high', label: 'High' },
|
|
4237
|
+
];
|
|
3371
4238
|
}
|
|
3372
4239
|
let effort;
|
|
3373
4240
|
if (effortOptions.length > 0) {
|
|
4241
|
+
const currentEffort = current?.provider === provider ? String(current?.reasoningEffort ?? '') : '';
|
|
4242
|
+
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
3374
4243
|
const effortAnswer = await this.askQuestion({
|
|
3375
4244
|
id: 'effort-pick',
|
|
3376
|
-
question: `选择思考强度(${
|
|
4245
|
+
question: `选择思考强度(${modelId})`,
|
|
3377
4246
|
options: effortOptions.map(option => ({
|
|
3378
4247
|
label: option.label,
|
|
3379
|
-
description: option.id ===
|
|
4248
|
+
description: option.id === currentEffort ? '当前' : undefined,
|
|
3380
4249
|
})),
|
|
3381
|
-
});
|
|
4250
|
+
}, 0, 1, currentIndex);
|
|
3382
4251
|
effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
3383
4252
|
}
|
|
3384
4253
|
const next = {
|
|
3385
4254
|
provider,
|
|
3386
|
-
model:
|
|
4255
|
+
model: modelId,
|
|
3387
4256
|
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
3388
4257
|
};
|
|
3389
4258
|
if (this.selectionRef !== undefined)
|
|
@@ -3393,8 +4262,9 @@ export class SshTui {
|
|
|
3393
4262
|
const kind = describeProviderRoute(provider);
|
|
3394
4263
|
this.pushRow({
|
|
3395
4264
|
kind: 'system',
|
|
3396
|
-
text: `已切换到 ${kind.kind}:${provider}/${
|
|
4265
|
+
text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
|
|
3397
4266
|
});
|
|
4267
|
+
await this.syncSubagentToProvider(provider, listed.filter(id => id !== '__switch_provider__' && id !== ''));
|
|
3398
4268
|
this.markDirty();
|
|
3399
4269
|
}
|
|
3400
4270
|
/** Provider route the next subagent request should use. */
|
|
@@ -3404,6 +4274,40 @@ export class SshTui {
|
|
|
3404
4274
|
?? this.agent.options.provider
|
|
3405
4275
|
?? this.providerName;
|
|
3406
4276
|
}
|
|
4277
|
+
/**
|
|
4278
|
+
* When the parent provider changes (OAuth or API key), keep the subagent
|
|
4279
|
+
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
4280
|
+
* switching to xAI is treated as stale.
|
|
4281
|
+
*/
|
|
4282
|
+
async syncSubagentToProvider(provider, listed = []) {
|
|
4283
|
+
const current = this.subagentSelection.current;
|
|
4284
|
+
if (current.provider !== undefined && current.provider !== provider)
|
|
4285
|
+
return;
|
|
4286
|
+
if (subagentModelMatchesProvider(provider, current.model, listed))
|
|
4287
|
+
return;
|
|
4288
|
+
let catalog = [...listed];
|
|
4289
|
+
if (catalog.length === 0) {
|
|
4290
|
+
try {
|
|
4291
|
+
const { options } = await this.subagentModelOptions(provider);
|
|
4292
|
+
catalog = options.map(option => option.id);
|
|
4293
|
+
}
|
|
4294
|
+
catch {
|
|
4295
|
+
catalog = [];
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
const nextModel = defaultSubagentModelForProvider(provider, catalog);
|
|
4299
|
+
if (nextModel === current.model)
|
|
4300
|
+
return;
|
|
4301
|
+
const persisted = await this.saveSubagentSelection({
|
|
4302
|
+
...current,
|
|
4303
|
+
model: nextModel,
|
|
4304
|
+
reasoningEffort: undefined,
|
|
4305
|
+
});
|
|
4306
|
+
this.pushRow({
|
|
4307
|
+
kind: 'system',
|
|
4308
|
+
text: `子代理已跟随提供商 ${provider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
3407
4311
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
3408
4312
|
async saveSubagentSelection(next) {
|
|
3409
4313
|
this.subagentSelection.current = next;
|
|
@@ -3879,9 +4783,39 @@ export class SshTui {
|
|
|
3879
4783
|
return;
|
|
3880
4784
|
}
|
|
3881
4785
|
if (combined.startsWith('\x1b') && combined.length > 1) {
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
4786
|
+
const alt = combined.slice(1);
|
|
4787
|
+
if (alt === '1') {
|
|
4788
|
+
this.jumpToCategory('thinking');
|
|
4789
|
+
return;
|
|
4790
|
+
}
|
|
4791
|
+
if (alt === '2') {
|
|
4792
|
+
this.jumpToCategory('plan');
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (alt === '3') {
|
|
4796
|
+
this.jumpToCategory('subagent');
|
|
4797
|
+
return;
|
|
4798
|
+
}
|
|
4799
|
+
if (alt === '4') {
|
|
4800
|
+
this.jumpToCategory('reply');
|
|
4801
|
+
return;
|
|
4802
|
+
}
|
|
4803
|
+
if (alt === 'n' || alt === 'N') {
|
|
4804
|
+
this.stepSearch(1);
|
|
4805
|
+
return;
|
|
4806
|
+
}
|
|
4807
|
+
if (alt === 'p' || alt === 'P') {
|
|
4808
|
+
this.stepSearch(-1);
|
|
4809
|
+
return;
|
|
4810
|
+
}
|
|
4811
|
+
if (alt === 'f' || alt === 'F' || alt === '/') {
|
|
4812
|
+
this.input = '/find ';
|
|
4813
|
+
this.cursor = this.input.length;
|
|
4814
|
+
this.markDirty();
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
// Other Alt+<key>: ignore ESC so it does not cancel, type the remainder.
|
|
4818
|
+
this.handlePlainText(alt);
|
|
3885
4819
|
return;
|
|
3886
4820
|
}
|
|
3887
4821
|
this.handlePlainText(combined);
|
|
@@ -3980,6 +4914,10 @@ export class SshTui {
|
|
|
3980
4914
|
void this.requestExit(0);
|
|
3981
4915
|
return;
|
|
3982
4916
|
case '\x0c':
|
|
4917
|
+
this.lastPaintRows = [];
|
|
4918
|
+
this.lastChromeKey = '';
|
|
4919
|
+
this.lastPaintWidth = 0;
|
|
4920
|
+
this.lastPaintHeight = 0;
|
|
3983
4921
|
this.dirty = true;
|
|
3984
4922
|
this.render();
|
|
3985
4923
|
return;
|
|
@@ -4019,6 +4957,16 @@ export class SshTui {
|
|
|
4019
4957
|
this.handleDialogChar(char);
|
|
4020
4958
|
return;
|
|
4021
4959
|
}
|
|
4960
|
+
if (char === '\x07') {
|
|
4961
|
+
this.stepSearch(1);
|
|
4962
|
+
return;
|
|
4963
|
+
}
|
|
4964
|
+
if (char === '\x1f') {
|
|
4965
|
+
this.input = '/find ';
|
|
4966
|
+
this.cursor = this.input.length;
|
|
4967
|
+
this.markDirty();
|
|
4968
|
+
return;
|
|
4969
|
+
}
|
|
4022
4970
|
if (char === '\t') {
|
|
4023
4971
|
if (this.suggestionsVisible()) {
|
|
4024
4972
|
const selected = this.commandSuggestions[this.suggestionIndex];
|
|
@@ -4307,6 +5255,7 @@ export class SshTui {
|
|
|
4307
5255
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
4308
5256
|
}
|
|
4309
5257
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
5258
|
+
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
4310
5259
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
4311
5260
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
4312
5261
|
this.pushRow({ kind: 'system', text: `Base URL 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
@@ -4369,6 +5318,7 @@ export class SshTui {
|
|
|
4369
5318
|
this.selectionRef.current = selection;
|
|
4370
5319
|
}
|
|
4371
5320
|
this.onSelectionChanged?.(selection);
|
|
5321
|
+
await this.syncSubagentToProvider(state.providerId, state.models);
|
|
4372
5322
|
this.pushRow({
|
|
4373
5323
|
kind: 'system',
|
|
4374
5324
|
text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
|
|
@@ -4623,10 +5573,12 @@ export class SshTui {
|
|
|
4623
5573
|
...local,
|
|
4624
5574
|
...dsh,
|
|
4625
5575
|
'',
|
|
4626
|
-
'运行中按 Enter 可插入指示;Esc
|
|
4627
|
-
'↑/↓
|
|
4628
|
-
'
|
|
4629
|
-
'/
|
|
5576
|
+
'运行中按 Enter 可插入指示;Esc 取消选择或当前轮次;空闲 Ctrl+C 退出。',
|
|
5577
|
+
'空输入时 ↑/↓ 选卡片(与 Ctrl+N/P 相同);Enter 展开;Ctrl+R 全部展开/收起;Ctrl+T 折叠输入。',
|
|
5578
|
+
'Alt+1 最新思考 · Alt+2 计划 · Alt+3 子代理 · Alt+4 最新回复。',
|
|
5579
|
+
'/find [思考|计划|子代理|回复] 关键字;Ctrl+/ 或 Alt+/ 打开搜索,Ctrl+G / Alt+N 下一条。',
|
|
5580
|
+
'/model 默认列出当前提供商的模型;当前是 SuperGrok 时直接选 grok-4.6 / grok-4.5 和思考强度(含 xhigh)。要换提供商再选「更换提供商」。',
|
|
5581
|
+
'/setup 只用于配置 API Key 提供商。SuperGrok / X Premium 走本机 OAuth,不需要填 Key。',
|
|
4630
5582
|
'/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
|
|
4631
5583
|
].join('\n'),
|
|
4632
5584
|
});
|
|
@@ -4680,12 +5632,20 @@ export class SshTui {
|
|
|
4680
5632
|
this.markDirty();
|
|
4681
5633
|
});
|
|
4682
5634
|
break;
|
|
5635
|
+
case 'find':
|
|
5636
|
+
this.runFindCommand(arg);
|
|
5637
|
+
break;
|
|
4683
5638
|
case 'clear':
|
|
4684
5639
|
this.rows.length = 0;
|
|
4685
5640
|
this.streaming = undefined;
|
|
4686
5641
|
this.streamingReasoning = undefined;
|
|
4687
5642
|
this.thinkingStartedAt = undefined;
|
|
4688
5643
|
this.focusedRow = null;
|
|
5644
|
+
this.searchHits = [];
|
|
5645
|
+
this.searchIndex = -1;
|
|
5646
|
+
this.searchQuery = '';
|
|
5647
|
+
this.planNudgePending = false;
|
|
5648
|
+
this.pendingReveal = undefined;
|
|
4689
5649
|
this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
|
|
4690
5650
|
break;
|
|
4691
5651
|
case 'status':
|
|
@@ -4753,7 +5713,7 @@ export class SshTui {
|
|
|
4753
5713
|
const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
|
|
4754
5714
|
return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
|
|
4755
5715
|
});
|
|
4756
|
-
this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n↑/↓
|
|
5716
|
+
this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n空输入时 ↑/↓ 选卡片,Enter 展开;Alt+3 跳到最新子代理。` });
|
|
4757
5717
|
}
|
|
4758
5718
|
break;
|
|
4759
5719
|
}
|