dsh-ssh-tui 0.2.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/README.en.md +65 -33
- package/README.md +51 -21
- package/cordis.patch.yml +2 -0
- package/docs/screenshots/compare.png +0 -0
- package/docs/screenshots/headless-labeled.png +0 -0
- package/docs/screenshots/headless.png +0 -0
- package/docs/screenshots/workspace-labeled.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 +1062 -116
- 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 +134 -5
- package/package.json +4 -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 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';
|
|
@@ -194,9 +234,29 @@ const DEEPSEEK_LOGO_VARIANTS = [
|
|
|
194
234
|
],
|
|
195
235
|
},
|
|
196
236
|
];
|
|
237
|
+
/** Human-facing kind for a live LLM route. */
|
|
238
|
+
export function describeProviderRoute(provider) {
|
|
239
|
+
const id = provider.trim();
|
|
240
|
+
if (id === 'deepseek-official' || id === 'deepseek') {
|
|
241
|
+
return { kind: 'DeepSeek 官方', short: 'DeepSeek 官方' };
|
|
242
|
+
}
|
|
243
|
+
if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
|
|
244
|
+
return { kind: 'SuperGrok / X Premium 订阅', short: 'SuperGrok' };
|
|
245
|
+
}
|
|
246
|
+
if (id === 'opencode-go')
|
|
247
|
+
return { kind: 'OpenCode Go', short: 'OpenCode Go' };
|
|
248
|
+
if (id === 'opencode')
|
|
249
|
+
return { kind: 'OpenCode Zen', short: 'OpenCode Zen' };
|
|
250
|
+
return { kind: '已注册提供商', short: id };
|
|
251
|
+
}
|
|
252
|
+
/** Routes that authenticate without a harness API-key credential. */
|
|
253
|
+
export function providerUsesLocalOAuth(provider) {
|
|
254
|
+
const id = provider.trim();
|
|
255
|
+
return id === 'xai' || id === 'grok' || id.startsWith('xai-');
|
|
256
|
+
}
|
|
197
257
|
const LOCAL_COMMANDS = [
|
|
198
258
|
{ name: 'help', description: 'show all available commands' },
|
|
199
|
-
{ name: 'model', description: 'select model and reasoning effort
|
|
259
|
+
{ name: 'model', description: 'select provider, model and reasoning effort' },
|
|
200
260
|
{ name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
|
|
201
261
|
{ name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
|
|
202
262
|
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
|
|
@@ -208,10 +268,23 @@ const LOCAL_COMMANDS = [
|
|
|
208
268
|
{ name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
|
|
209
269
|
{ name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
|
|
210
270
|
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
211
|
-
{ 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' },
|
|
212
273
|
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
213
274
|
];
|
|
214
|
-
|
|
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) {
|
|
215
288
|
let width = 0;
|
|
216
289
|
for (const char of text) {
|
|
217
290
|
if (char === '\t') {
|
|
@@ -221,11 +294,19 @@ function displayWidth(text) {
|
|
|
221
294
|
continue;
|
|
222
295
|
}
|
|
223
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
|
+
}
|
|
224
303
|
const wide = (cp >= 0x1100 && cp <= 0x115f) ||
|
|
304
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
225
305
|
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
226
306
|
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
227
307
|
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
228
|
-
(cp >=
|
|
308
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
309
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
229
310
|
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
230
311
|
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
231
312
|
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
@@ -234,6 +315,100 @@ function displayWidth(text) {
|
|
|
234
315
|
}
|
|
235
316
|
return width;
|
|
236
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
|
+
}
|
|
237
412
|
/** Strip terminal control sequences and expand tabs for display output. */
|
|
238
413
|
function sanitizeTerminalText(text) {
|
|
239
414
|
return text
|
|
@@ -246,6 +421,7 @@ function firstCodePointLength(text) {
|
|
|
246
421
|
return Array.from(text)[0]?.length ?? 1;
|
|
247
422
|
}
|
|
248
423
|
function wrap(text, width) {
|
|
424
|
+
const limit = Math.max(1, width);
|
|
249
425
|
const lines = [];
|
|
250
426
|
for (const sourceLine of text.split('\n')) {
|
|
251
427
|
if (sourceLine === '') {
|
|
@@ -253,18 +429,21 @@ function wrap(text, width) {
|
|
|
253
429
|
continue;
|
|
254
430
|
}
|
|
255
431
|
let rest = sanitizeTerminalText(sourceLine);
|
|
256
|
-
while (displayWidth(rest) >
|
|
432
|
+
while (displayWidth(rest) > limit) {
|
|
257
433
|
let cut = 0;
|
|
258
434
|
let used = 0;
|
|
259
435
|
for (const char of rest) {
|
|
260
436
|
const charWidth = displayWidth(char);
|
|
261
|
-
if (used + charWidth >
|
|
437
|
+
if (charWidth > 0 && used + charWidth > limit)
|
|
262
438
|
break;
|
|
263
439
|
used += charWidth;
|
|
264
440
|
cut += char.length;
|
|
265
441
|
}
|
|
266
|
-
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.
|
|
267
445
|
cut = firstCodePointLength(rest);
|
|
446
|
+
}
|
|
268
447
|
lines.push(rest.slice(0, cut));
|
|
269
448
|
rest = rest.slice(cut);
|
|
270
449
|
}
|
|
@@ -344,8 +523,14 @@ function wrapMarkdownSegments(segments, width, prefixSegments = []) {
|
|
|
344
523
|
const slice = forwardSliceByWidth(rest, available);
|
|
345
524
|
let chunk = slice.text;
|
|
346
525
|
if (chunk === '') {
|
|
347
|
-
// A wide character does not fit the remaining cell
|
|
348
|
-
//
|
|
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
|
+
}
|
|
349
534
|
chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
|
|
350
535
|
}
|
|
351
536
|
current.push({ kind: segment.kind, text: chunk });
|
|
@@ -483,7 +668,7 @@ export function renderMarkdownLines(text, width, color) {
|
|
|
483
668
|
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
|
|
484
669
|
lines.push(renderMarkdownBlockLine({
|
|
485
670
|
base: 'rule',
|
|
486
|
-
segments: [{ kind: 'text', text: '─'
|
|
671
|
+
segments: [{ kind: 'text', text: repeatToWidth('─', Math.max(1, width)) }],
|
|
487
672
|
}, color));
|
|
488
673
|
continue;
|
|
489
674
|
}
|
|
@@ -539,6 +724,37 @@ export function truncateToWidth(text, width) {
|
|
|
539
724
|
cut = firstCodePointLength(safe);
|
|
540
725
|
return `${safe.slice(0, cut)}…`;
|
|
541
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
|
+
}
|
|
542
758
|
/** Slice up to `maxWidth` display columns from the beginning of `text`. */
|
|
543
759
|
function forwardSliceByWidth(text, maxWidth) {
|
|
544
760
|
let cut = 0;
|
|
@@ -857,9 +1073,167 @@ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
|
|
|
857
1073
|
const MAX_SUBAGENT_LOGS = 80;
|
|
858
1074
|
const TODO_STATUS_MARK = {
|
|
859
1075
|
pending: '○',
|
|
860
|
-
in_progress: '
|
|
861
|
-
completed: '
|
|
1076
|
+
in_progress: '◐',
|
|
1077
|
+
completed: '●',
|
|
862
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
|
+
}
|
|
863
1237
|
/** Parse a todo_write payload into displayable plan items. */
|
|
864
1238
|
export function parsePlanTodos(value) {
|
|
865
1239
|
const root = typeof value === 'string' ? parseJsonArgs(value) : value;
|
|
@@ -1005,13 +1379,40 @@ export function presentToolCall(name, args) {
|
|
|
1005
1379
|
};
|
|
1006
1380
|
}
|
|
1007
1381
|
if (name === 'todo_write' || name === 'todo') {
|
|
1008
|
-
return { title: '
|
|
1382
|
+
return { title: '更新待办', summary: todoSummary(parsed) };
|
|
1009
1383
|
}
|
|
1010
1384
|
if (name === 'ask_user_question') {
|
|
1011
1385
|
return { title: '提问用户', summary: askSummary(parsed) };
|
|
1012
1386
|
}
|
|
1013
1387
|
if (name === 'exit_plan_mode') {
|
|
1014
|
-
|
|
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) };
|
|
1015
1416
|
}
|
|
1016
1417
|
return { title: name, summary: friendlyArgsSummary(name, args) };
|
|
1017
1418
|
}
|
|
@@ -1192,6 +1593,9 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1192
1593
|
}
|
|
1193
1594
|
return out;
|
|
1194
1595
|
}
|
|
1596
|
+
const specialized = specializedToolBody(row);
|
|
1597
|
+
if (specialized !== null)
|
|
1598
|
+
return capDisplayLines(specialized, maxLines);
|
|
1195
1599
|
const out = [];
|
|
1196
1600
|
const args = parseJsonArgs(row.args);
|
|
1197
1601
|
if (args !== null && Object.keys(args).length > 0) {
|
|
@@ -1216,6 +1620,86 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1216
1620
|
}
|
|
1217
1621
|
return capDisplayLines(out, maxLines);
|
|
1218
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
|
+
}
|
|
1219
1703
|
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
1220
1704
|
export function parseExitStatus(text) {
|
|
1221
1705
|
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
|
|
@@ -1326,6 +1810,12 @@ export class SshTui {
|
|
|
1326
1810
|
lastTitleUpdateAt = 0;
|
|
1327
1811
|
lastPaintRows = [];
|
|
1328
1812
|
lastChromeKey = '';
|
|
1813
|
+
lastPaintWidth = 0;
|
|
1814
|
+
lastPaintHeight = 0;
|
|
1815
|
+
paintIntervalMs;
|
|
1816
|
+
searchHits = [];
|
|
1817
|
+
searchIndex = -1;
|
|
1818
|
+
searchQuery = '';
|
|
1329
1819
|
constructor(ctx, agent, config) {
|
|
1330
1820
|
this.ctx = ctx;
|
|
1331
1821
|
this.agent = agent;
|
|
@@ -1346,9 +1836,10 @@ export class SshTui {
|
|
|
1346
1836
|
this.presetId = config.presetId ?? 'standard';
|
|
1347
1837
|
this.presetName = config.presetName ?? this.presetId;
|
|
1348
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);
|
|
1349
1840
|
this.pushRow({ kind: 'brand-logo' });
|
|
1350
1841
|
this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
|
|
1351
|
-
this.pushRow({ kind: 'system', text: '输入 /help
|
|
1842
|
+
this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
|
|
1352
1843
|
}
|
|
1353
1844
|
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
1354
1845
|
start() {
|
|
@@ -1377,7 +1868,7 @@ export class SshTui {
|
|
|
1377
1868
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
1378
1869
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
1379
1870
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
|
|
1380
|
-
if (animating && now - this.lastPaintAt >= 200) {
|
|
1871
|
+
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1381
1872
|
this.dirty = true;
|
|
1382
1873
|
}
|
|
1383
1874
|
// While a turn is waiting on the provider with no new events, repaint at
|
|
@@ -1394,7 +1885,7 @@ export class SshTui {
|
|
|
1394
1885
|
this.lastPaintAt = now;
|
|
1395
1886
|
this.render();
|
|
1396
1887
|
}
|
|
1397
|
-
},
|
|
1888
|
+
}, this.paintIntervalMs);
|
|
1398
1889
|
this.renderTimer.unref?.();
|
|
1399
1890
|
void this.maybeRunOnboarding().catch((error) => {
|
|
1400
1891
|
if (this.disposed)
|
|
@@ -1402,6 +1893,12 @@ export class SshTui {
|
|
|
1402
1893
|
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
1403
1894
|
this.markDirty();
|
|
1404
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
|
+
});
|
|
1405
1902
|
}
|
|
1406
1903
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1407
1904
|
replayHistory() {
|
|
@@ -1423,7 +1920,15 @@ export class SshTui {
|
|
|
1423
1920
|
/** Show the first-launch provider/API-key onboarding when nothing is configured. */
|
|
1424
1921
|
async maybeRunOnboarding() {
|
|
1425
1922
|
const credentials = this.ctx.get('credentials');
|
|
1426
|
-
const provider = this.
|
|
1923
|
+
const provider = this.currentProviderId();
|
|
1924
|
+
if (providerUsesLocalOAuth(provider)) {
|
|
1925
|
+
this.pushRow({
|
|
1926
|
+
kind: 'system',
|
|
1927
|
+
text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),走本机 SuperGrok / X Premium OAuth,无需 API Key。用 /model 切换 Grok 模型和思考强度;只有要改成 DeepSeek 官方或 OpenCode 这类 Key 提供商时才需要 /setup。`,
|
|
1928
|
+
});
|
|
1929
|
+
this.markDirty();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1427
1932
|
const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
|
|
1428
1933
|
const envKey = process.env[envRef];
|
|
1429
1934
|
let stored = false;
|
|
@@ -1580,6 +2085,24 @@ export class SshTui {
|
|
|
1580
2085
|
process.exit(code);
|
|
1581
2086
|
}
|
|
1582
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
|
+
}
|
|
1583
2106
|
write(chunk) {
|
|
1584
2107
|
process.stdout.write(chunk);
|
|
1585
2108
|
}
|
|
@@ -1620,24 +2143,106 @@ export class SshTui {
|
|
|
1620
2143
|
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
1621
2144
|
}
|
|
1622
2145
|
findLivePlanRow() {
|
|
1623
|
-
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
|
+
}
|
|
1624
2160
|
}
|
|
1625
2161
|
upsertPlanRow(patch) {
|
|
1626
2162
|
const existing = this.findLivePlanRow();
|
|
1627
|
-
|
|
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)) {
|
|
1628
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);
|
|
1629
2174
|
return existing;
|
|
1630
2175
|
}
|
|
2176
|
+
if (existing !== undefined) {
|
|
2177
|
+
existing.archived = true;
|
|
2178
|
+
existing.active = false;
|
|
2179
|
+
existing.pending = false;
|
|
2180
|
+
existing.expanded = false;
|
|
2181
|
+
}
|
|
1631
2182
|
const row = {
|
|
1632
2183
|
kind: 'plan',
|
|
1633
2184
|
active: patch.active ?? false,
|
|
1634
2185
|
pending: patch.pending ?? false,
|
|
1635
2186
|
todos: patch.todos ?? [],
|
|
2187
|
+
...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
|
|
1636
2188
|
expanded: false,
|
|
2189
|
+
archived: false,
|
|
1637
2190
|
};
|
|
1638
2191
|
this.pushRow(row);
|
|
2192
|
+
this.archiveStalePlans(row);
|
|
1639
2193
|
return row;
|
|
1640
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
|
+
}
|
|
1641
2246
|
paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
|
|
1642
2247
|
const focused = this.focusedRow === row;
|
|
1643
2248
|
const marker = row.expanded ? '▾' : '▸';
|
|
@@ -1699,6 +2304,83 @@ export class SshTui {
|
|
|
1699
2304
|
this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
|
|
1700
2305
|
this.markDirty();
|
|
1701
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
|
+
}
|
|
1702
2384
|
paint = () => {
|
|
1703
2385
|
if (this.exiting)
|
|
1704
2386
|
return;
|
|
@@ -1738,7 +2420,7 @@ export class SshTui {
|
|
|
1738
2420
|
const focused = this.focusedRow === row;
|
|
1739
2421
|
const marker = row.expanded ? '▾' : '▸';
|
|
1740
2422
|
const lines = row.text.split('\n').length;
|
|
1741
|
-
const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' ·
|
|
2423
|
+
const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1742
2424
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
1743
2425
|
const styled = this.styleLine('reasoning', line);
|
|
1744
2426
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
@@ -1757,10 +2439,13 @@ export class SshTui {
|
|
|
1757
2439
|
// "[33m" text on screen; color the dot between two sanitized halves.
|
|
1758
2440
|
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1759
2441
|
const styleToolHeader = (line) => {
|
|
1760
|
-
const
|
|
2442
|
+
const safe = sanitizeTerminalText(line);
|
|
2443
|
+
if (!this.color)
|
|
2444
|
+
return safe;
|
|
2445
|
+
const dotIndex = safe.indexOf('●');
|
|
1761
2446
|
if (dotColor === undefined || dotIndex === -1)
|
|
1762
|
-
return this.styleLine('tool',
|
|
1763
|
-
return
|
|
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`;
|
|
1764
2449
|
};
|
|
1765
2450
|
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
1766
2451
|
const state = running ? 'running…' : ok ? 'ok' : 'error';
|
|
@@ -1785,8 +2470,11 @@ export class SshTui {
|
|
|
1785
2470
|
addDisplay(styleToolHeader(wrapped), row);
|
|
1786
2471
|
}
|
|
1787
2472
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
1788
|
-
|
|
1789
|
-
|
|
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));
|
|
1790
2478
|
}
|
|
1791
2479
|
}
|
|
1792
2480
|
continue;
|
|
@@ -1796,10 +2484,13 @@ export class SshTui {
|
|
|
1796
2484
|
const ok = row.status === 'ok';
|
|
1797
2485
|
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1798
2486
|
const styleHeader = (line) => {
|
|
1799
|
-
const
|
|
2487
|
+
const safe = sanitizeTerminalText(line);
|
|
2488
|
+
if (!this.color)
|
|
2489
|
+
return safe;
|
|
2490
|
+
const dotIndex = safe.indexOf('●');
|
|
1800
2491
|
if (dotColor === undefined || dotIndex === -1)
|
|
1801
|
-
return this.styleLine('tool',
|
|
1802
|
-
return
|
|
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`;
|
|
1803
2494
|
};
|
|
1804
2495
|
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
1805
2496
|
const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
@@ -1828,30 +2519,24 @@ export class SshTui {
|
|
|
1828
2519
|
continue;
|
|
1829
2520
|
}
|
|
1830
2521
|
if (row.kind === 'plan') {
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
const
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
const header = `● ${mode}${spinner} · ${todoSummary(row.todos)}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
1839
|
-
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);
|
|
1840
2529
|
if (row.expanded) {
|
|
1841
|
-
addDisplay(this.styleLine('
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
if (row.todos.length === 0) {
|
|
1847
|
-
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
|
+
}
|
|
1848
2535
|
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
addDisplay(this.styleLine(item.status === 'completed' ? 'system' : 'tool', ` ${wrapped}`));
|
|
1854
|
-
}
|
|
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}`));
|
|
1855
2540
|
}
|
|
1856
2541
|
}
|
|
1857
2542
|
}
|
|
@@ -1871,8 +2556,15 @@ export class SshTui {
|
|
|
1871
2556
|
addDisplay(this.styleLine('assistant', ` ${wrapped}`));
|
|
1872
2557
|
}
|
|
1873
2558
|
if (row.detail !== undefined && row.detail !== '') {
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
+
}
|
|
1876
2568
|
}
|
|
1877
2569
|
}
|
|
1878
2570
|
addDisplay(this.styleLine('system', waiting
|
|
@@ -2007,7 +2699,9 @@ export class SshTui {
|
|
|
2007
2699
|
addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
|
|
2008
2700
|
addDialog(d.question.question);
|
|
2009
2701
|
if (d.question.detail !== undefined && d.question.detail !== '') {
|
|
2010
|
-
|
|
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
|
+
}
|
|
2011
2705
|
}
|
|
2012
2706
|
}
|
|
2013
2707
|
else {
|
|
@@ -2036,12 +2730,11 @@ export class SshTui {
|
|
|
2036
2730
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
2037
2731
|
const headerLines = [
|
|
2038
2732
|
this.styleLine('system', fitLine(`DeepSeek Harness — SSH TUI [${this.presetName}] ${this.currentSelectionLabel()}`)),
|
|
2039
|
-
this.styleLine('system', '─'
|
|
2733
|
+
this.styleLine('system', repeatToWidth('─', width)),
|
|
2040
2734
|
];
|
|
2041
2735
|
if (this.scrollOffset > 0) {
|
|
2042
2736
|
headerLines.push(this.styleLine('system', fitLine(`↑ 已回看 ${this.scrollOffset} 行 · PgUp/PgDn/滚轮滚动 · Esc 回到底部`)));
|
|
2043
2737
|
}
|
|
2044
|
-
const inputDivider = this.styleLine('system', '─'.repeat(width));
|
|
2045
2738
|
this.commandSuggestions = this.dialog === undefined ? this.buildSuggestions() : [];
|
|
2046
2739
|
if (this.suggestionIndex >= this.commandSuggestions.length) {
|
|
2047
2740
|
this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
|
|
@@ -2059,7 +2752,7 @@ export class SshTui {
|
|
|
2059
2752
|
const promptWidth = displayWidth(promptPlain);
|
|
2060
2753
|
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
2061
2754
|
const inputView = masked
|
|
2062
|
-
? { text: '•'.repeat(this.input.length), cursorOffset: this.cursor, folded: false }
|
|
2755
|
+
? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
|
|
2063
2756
|
: this.inputFolded
|
|
2064
2757
|
? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
|
|
2065
2758
|
: { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
|
|
@@ -2104,7 +2797,12 @@ export class SshTui {
|
|
|
2104
2797
|
}
|
|
2105
2798
|
}
|
|
2106
2799
|
const inputRows = Math.max(1, inputDisplayLines.length);
|
|
2107
|
-
const
|
|
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;
|
|
2108
2806
|
const available = Math.max(1, height - reserved - dialogLines.length);
|
|
2109
2807
|
const maxOffset = Math.max(0, display.length - available);
|
|
2110
2808
|
if (this.scrollOffset > maxOffset)
|
|
@@ -2123,6 +2821,11 @@ export class SshTui {
|
|
|
2123
2821
|
if (ref !== undefined)
|
|
2124
2822
|
this.clickableRows.set(headerLines.length + index + 1, ref);
|
|
2125
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
|
+
}
|
|
2126
2829
|
const statsText = this.statsText();
|
|
2127
2830
|
const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
|
|
2128
2831
|
let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
|
|
@@ -2132,6 +2835,9 @@ export class SshTui {
|
|
|
2132
2835
|
statusText += sub.provider === undefined
|
|
2133
2836
|
? ` · sub:${sub.model}${subEffort}`
|
|
2134
2837
|
: ` · sub:${subProvider}/${sub.model}${subEffort}`;
|
|
2838
|
+
if (this.searchHits.length > 0 && this.searchIndex >= 0) {
|
|
2839
|
+
statusText += ` · 搜索 ${this.searchIndex + 1}/${this.searchHits.length}`;
|
|
2840
|
+
}
|
|
2135
2841
|
if (inputView.folded)
|
|
2136
2842
|
statusText += ' · 输入已折叠 · Ctrl+T 展开';
|
|
2137
2843
|
else if (inputRows > 1)
|
|
@@ -2168,6 +2874,7 @@ export class SshTui {
|
|
|
2168
2874
|
const paintRows = [
|
|
2169
2875
|
...headerLines,
|
|
2170
2876
|
...visible,
|
|
2877
|
+
...planDockLines,
|
|
2171
2878
|
...dialogLines,
|
|
2172
2879
|
inputDivider,
|
|
2173
2880
|
...suggestionLines,
|
|
@@ -2178,7 +2885,7 @@ export class SshTui {
|
|
|
2178
2885
|
// Bottom chrome is force-repainted whenever its state changes while the
|
|
2179
2886
|
// agent is working; this clears any stale cell left behind by a previous
|
|
2180
2887
|
// frame even when the row strings happen to be identical.
|
|
2181
|
-
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);
|
|
2182
2889
|
const chromeKey = [
|
|
2183
2890
|
this.status,
|
|
2184
2891
|
this.agent.status,
|
|
@@ -2189,33 +2896,37 @@ export class SshTui {
|
|
|
2189
2896
|
inputView.folded,
|
|
2190
2897
|
inputRows,
|
|
2191
2898
|
paintRows.length,
|
|
2899
|
+
width,
|
|
2900
|
+
height,
|
|
2192
2901
|
this.pendingMessages.size,
|
|
2193
2902
|
this.commandSuggestions.length,
|
|
2194
2903
|
this.suggestionIndex,
|
|
2195
2904
|
this.activeSubagents.size,
|
|
2196
2905
|
this.dialog?.kind ?? '',
|
|
2197
|
-
|
|
2906
|
+
planDockLines.join('\n'),
|
|
2198
2907
|
].join('\x1f');
|
|
2199
2908
|
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
2200
|
-
|
|
2201
|
-
//
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
const current = paintRows[i];
|
|
2206
|
-
if (current === this.lastPaintRows[i] && !(chromeChanged && i >= chromeStart))
|
|
2207
|
-
continue;
|
|
2208
|
-
this.write(`\x1b[${i + 1};1H\x1b[0m${current ?? ''}\x1b[K`);
|
|
2209
|
-
}
|
|
2210
|
-
if (paintRows.length < this.lastPaintRows.length) {
|
|
2211
|
-
this.write(`\x1b[${paintRows.length + 1};1H\x1b[J`);
|
|
2212
|
-
}
|
|
2213
|
-
this.write('\x1b[0m');
|
|
2214
|
-
this.lastPaintRows = paintRows;
|
|
2215
|
-
this.lastChromeKey = chromeKey;
|
|
2216
|
-
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;
|
|
2217
2914
|
const row = Math.min(height, inputTopRow + cursorRowOffset);
|
|
2218
|
-
this.write(
|
|
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;
|
|
2219
2930
|
};
|
|
2220
2931
|
buildSuggestions() {
|
|
2221
2932
|
const input = this.input;
|
|
@@ -2239,12 +2950,16 @@ export class SshTui {
|
|
|
2239
2950
|
suggestionsVisible() {
|
|
2240
2951
|
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
2241
2952
|
}
|
|
2953
|
+
currentProviderId() {
|
|
2954
|
+
return this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
2955
|
+
}
|
|
2242
2956
|
currentSelectionLabel() {
|
|
2243
2957
|
const current = this.selectionRef?.current;
|
|
2244
|
-
const provider =
|
|
2958
|
+
const provider = this.currentProviderId();
|
|
2245
2959
|
const model = current?.model ?? this.agent.options.model ?? 'unknown';
|
|
2246
2960
|
const effort = current?.reasoningEffort;
|
|
2247
|
-
|
|
2961
|
+
const kind = describeProviderRoute(provider).short;
|
|
2962
|
+
return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
|
|
2248
2963
|
}
|
|
2249
2964
|
/** Replace one step's usage sample so a repeated report never double counts. */
|
|
2250
2965
|
recordUsage(turn, step, usage) {
|
|
@@ -2373,9 +3088,13 @@ export class SshTui {
|
|
|
2373
3088
|
kind === 'diff-add' ? '38;5;22;48;5;194' :
|
|
2374
3089
|
kind === 'diff-del' ? '38;5;124;48;5;224' :
|
|
2375
3090
|
kind === 'diff-path' ? '1;36' :
|
|
2376
|
-
kind === '
|
|
2377
|
-
'
|
|
2378
|
-
|
|
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`;
|
|
2379
3098
|
}
|
|
2380
3099
|
// ── event handling ──────────────────────────────────────────────────────
|
|
2381
3100
|
/**
|
|
@@ -2389,10 +3108,15 @@ export class SshTui {
|
|
|
2389
3108
|
if (agent === this.agent)
|
|
2390
3109
|
return resolved;
|
|
2391
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);
|
|
2392
3116
|
return {
|
|
2393
3117
|
...resolved,
|
|
2394
|
-
|
|
2395
|
-
model
|
|
3118
|
+
provider,
|
|
3119
|
+
model,
|
|
2396
3120
|
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
|
|
2397
3121
|
};
|
|
2398
3122
|
};
|
|
@@ -2519,6 +3243,11 @@ export class SshTui {
|
|
|
2519
3243
|
expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
|
|
2520
3244
|
};
|
|
2521
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
|
+
}
|
|
2522
3251
|
this.streaming = undefined;
|
|
2523
3252
|
this.markDirty();
|
|
2524
3253
|
break;
|
|
@@ -3064,13 +3793,13 @@ export class SshTui {
|
|
|
3064
3793
|
this.removeQueuedDialog(dialog);
|
|
3065
3794
|
dialog.resolve('cancel');
|
|
3066
3795
|
}
|
|
3067
|
-
openQuestion(question, index, total, resolve, reject) {
|
|
3796
|
+
openQuestion(question, index, total, resolve, reject, preselected) {
|
|
3068
3797
|
const dialog = {
|
|
3069
3798
|
kind: 'questions',
|
|
3070
3799
|
question,
|
|
3071
3800
|
index,
|
|
3072
3801
|
total,
|
|
3073
|
-
selected: new Set(),
|
|
3802
|
+
selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
|
|
3074
3803
|
resolve: (selection) => {
|
|
3075
3804
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
3076
3805
|
},
|
|
@@ -3082,9 +3811,9 @@ export class SshTui {
|
|
|
3082
3811
|
return dialog;
|
|
3083
3812
|
}
|
|
3084
3813
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
3085
|
-
askQuestion(question, index = 0, total = 1) {
|
|
3814
|
+
askQuestion(question, index = 0, total = 1, preselected) {
|
|
3086
3815
|
return new Promise((resolve, reject) => {
|
|
3087
|
-
this.openQuestion(question, index, total, resolve, reject);
|
|
3816
|
+
this.openQuestion(question, index, total, resolve, reject, preselected);
|
|
3088
3817
|
});
|
|
3089
3818
|
}
|
|
3090
3819
|
/** The stored llm-pi-ai profile for one provider route, when settings provide one. */
|
|
@@ -3206,11 +3935,12 @@ export class SshTui {
|
|
|
3206
3935
|
options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
|
|
3207
3936
|
if (hasNext)
|
|
3208
3937
|
options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
|
|
3938
|
+
const currentIndex = page.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
|
|
3209
3939
|
const answer = await this.askQuestion({
|
|
3210
3940
|
id: 'model-pick',
|
|
3211
3941
|
question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
|
|
3212
3942
|
options,
|
|
3213
|
-
});
|
|
3943
|
+
}, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
|
|
3214
3944
|
const picked = options.find(option => option.label === answer.selected[0]);
|
|
3215
3945
|
if (picked === undefined)
|
|
3216
3946
|
return undefined;
|
|
@@ -3225,11 +3955,58 @@ export class SshTui {
|
|
|
3225
3955
|
return page.find(option => option.label === picked.label);
|
|
3226
3956
|
}
|
|
3227
3957
|
}
|
|
3228
|
-
/**
|
|
3958
|
+
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
3959
|
+
listSelectableProviders() {
|
|
3960
|
+
const llm = this.ctx.get('llm');
|
|
3961
|
+
const current = this.currentProviderId();
|
|
3962
|
+
const seen = new Set();
|
|
3963
|
+
const out = [];
|
|
3964
|
+
const add = (id, name) => {
|
|
3965
|
+
if (id === '' || seen.has(id))
|
|
3966
|
+
return;
|
|
3967
|
+
seen.add(id);
|
|
3968
|
+
const kind = describeProviderRoute(id);
|
|
3969
|
+
const display = name !== undefined && name !== '' && name !== id ? name : kind.short;
|
|
3970
|
+
out.push({ id, label: `${display} · ${id}` });
|
|
3971
|
+
};
|
|
3972
|
+
add(current);
|
|
3973
|
+
for (const info of llm?.listProviders() ?? [])
|
|
3974
|
+
add(info.id, info.name);
|
|
3975
|
+
add('xai', 'SuperGrok');
|
|
3976
|
+
add('deepseek-official', 'DeepSeek 官方');
|
|
3977
|
+
add('opencode-go', 'OpenCode Go');
|
|
3978
|
+
add('opencode', 'OpenCode Zen');
|
|
3979
|
+
return out;
|
|
3980
|
+
}
|
|
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. */
|
|
3229
3988
|
async runModelCommand() {
|
|
3230
3989
|
const llm = this.ctx.get('llm');
|
|
3231
3990
|
const current = this.selectionRef?.current;
|
|
3232
|
-
const
|
|
3991
|
+
const providers = this.listSelectableProviders();
|
|
3992
|
+
let provider = this.currentProviderId();
|
|
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({
|
|
3999
|
+
id: 'provider-pick',
|
|
4000
|
+
question: '选择提供商',
|
|
4001
|
+
options: providers.map(option => ({
|
|
4002
|
+
label: option.label,
|
|
4003
|
+
description: option.id === provider
|
|
4004
|
+
? `${describeProviderRoute(option.id).kind} · 当前`
|
|
4005
|
+
: describeProviderRoute(option.id).kind,
|
|
4006
|
+
})),
|
|
4007
|
+
}, 0, 1, currentIndex);
|
|
4008
|
+
return providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
4009
|
+
};
|
|
3233
4010
|
let modelOptions = [];
|
|
3234
4011
|
let modelSource = '已配置列表';
|
|
3235
4012
|
// OpenCode and other third-party routes are interrogated live so the picker
|
|
@@ -3276,52 +4053,127 @@ export class SshTui {
|
|
|
3276
4053
|
modelOptions = [];
|
|
3277
4054
|
}
|
|
3278
4055
|
}
|
|
4056
|
+
if (modelOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
4057
|
+
modelOptions = SshTui.XAI_FALLBACK_MODELS.map(option => ({ ...option }));
|
|
4058
|
+
modelSource = 'SuperGrok 目录';
|
|
4059
|
+
}
|
|
3279
4060
|
if (modelOptions.length === 0) {
|
|
3280
|
-
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');
|
|
3281
4062
|
modelOptions = [{ id: fallback, label: fallback }];
|
|
3282
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
|
+
}
|
|
3283
4073
|
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
3284
4074
|
if (selected === undefined)
|
|
3285
4075
|
return;
|
|
3286
|
-
if (
|
|
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)))
|
|
3287
4121
|
return;
|
|
4122
|
+
const llm = this.ctx.get('llm');
|
|
4123
|
+
const current = this.selectionRef?.current;
|
|
3288
4124
|
let effortOptions = [];
|
|
3289
4125
|
try {
|
|
3290
|
-
const info = await llm?.resolveModelInfo(provider,
|
|
4126
|
+
const info = await llm?.resolveModelInfo(provider, modelId);
|
|
3291
4127
|
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
3292
4128
|
}
|
|
3293
4129
|
catch {
|
|
3294
4130
|
effortOptions = [];
|
|
3295
4131
|
}
|
|
3296
|
-
if (effortOptions.length === 0) {
|
|
3297
|
-
|
|
3298
|
-
|
|
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
|
+
];
|
|
3299
4147
|
}
|
|
3300
4148
|
let effort;
|
|
3301
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));
|
|
3302
4152
|
const effortAnswer = await this.askQuestion({
|
|
3303
4153
|
id: 'effort-pick',
|
|
3304
|
-
question: `选择思考强度(${
|
|
4154
|
+
question: `选择思考强度(${modelId})`,
|
|
3305
4155
|
options: effortOptions.map(option => ({
|
|
3306
4156
|
label: option.label,
|
|
3307
|
-
description: option.id ===
|
|
4157
|
+
description: option.id === currentEffort ? '当前' : undefined,
|
|
3308
4158
|
})),
|
|
3309
|
-
});
|
|
4159
|
+
}, 0, 1, currentIndex);
|
|
3310
4160
|
effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
3311
4161
|
}
|
|
3312
4162
|
const next = {
|
|
3313
4163
|
provider,
|
|
3314
|
-
model:
|
|
4164
|
+
model: modelId,
|
|
3315
4165
|
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
3316
4166
|
};
|
|
3317
4167
|
if (this.selectionRef !== undefined)
|
|
3318
4168
|
this.selectionRef.current = next;
|
|
3319
4169
|
this.onSelectionChanged?.(next);
|
|
3320
4170
|
await this.ctx.get('agentDefaultModel')?.saveSelection(next);
|
|
4171
|
+
const kind = describeProviderRoute(provider);
|
|
3321
4172
|
this.pushRow({
|
|
3322
4173
|
kind: 'system',
|
|
3323
|
-
text:
|
|
4174
|
+
text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
|
|
3324
4175
|
});
|
|
4176
|
+
await this.syncSubagentToProvider(provider, listed.filter(id => id !== '__switch_provider__' && id !== ''));
|
|
3325
4177
|
this.markDirty();
|
|
3326
4178
|
}
|
|
3327
4179
|
/** Provider route the next subagent request should use. */
|
|
@@ -3331,6 +4183,40 @@ export class SshTui {
|
|
|
3331
4183
|
?? this.agent.options.provider
|
|
3332
4184
|
?? this.providerName;
|
|
3333
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
|
+
}
|
|
3334
4220
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
3335
4221
|
async saveSubagentSelection(next) {
|
|
3336
4222
|
this.subagentSelection.current = next;
|
|
@@ -3806,9 +4692,39 @@ export class SshTui {
|
|
|
3806
4692
|
return;
|
|
3807
4693
|
}
|
|
3808
4694
|
if (combined.startsWith('\x1b') && combined.length > 1) {
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
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);
|
|
3812
4728
|
return;
|
|
3813
4729
|
}
|
|
3814
4730
|
this.handlePlainText(combined);
|
|
@@ -3907,6 +4823,10 @@ export class SshTui {
|
|
|
3907
4823
|
void this.requestExit(0);
|
|
3908
4824
|
return;
|
|
3909
4825
|
case '\x0c':
|
|
4826
|
+
this.lastPaintRows = [];
|
|
4827
|
+
this.lastChromeKey = '';
|
|
4828
|
+
this.lastPaintWidth = 0;
|
|
4829
|
+
this.lastPaintHeight = 0;
|
|
3910
4830
|
this.dirty = true;
|
|
3911
4831
|
this.render();
|
|
3912
4832
|
return;
|
|
@@ -3946,6 +4866,16 @@ export class SshTui {
|
|
|
3946
4866
|
this.handleDialogChar(char);
|
|
3947
4867
|
return;
|
|
3948
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
|
+
}
|
|
3949
4879
|
if (char === '\t') {
|
|
3950
4880
|
if (this.suggestionsVisible()) {
|
|
3951
4881
|
const selected = this.commandSuggestions[this.suggestionIndex];
|
|
@@ -4234,6 +5164,7 @@ export class SshTui {
|
|
|
4234
5164
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
4235
5165
|
}
|
|
4236
5166
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
5167
|
+
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
4237
5168
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
4238
5169
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
4239
5170
|
this.pushRow({ kind: 'system', text: `Base URL 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
@@ -4296,6 +5227,7 @@ export class SshTui {
|
|
|
4296
5227
|
this.selectionRef.current = selection;
|
|
4297
5228
|
}
|
|
4298
5229
|
this.onSelectionChanged?.(selection);
|
|
5230
|
+
await this.syncSubagentToProvider(state.providerId, state.models);
|
|
4299
5231
|
this.pushRow({
|
|
4300
5232
|
kind: 'system',
|
|
4301
5233
|
text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
|
|
@@ -4550,9 +5482,13 @@ export class SshTui {
|
|
|
4550
5482
|
...local,
|
|
4551
5483
|
...dsh,
|
|
4552
5484
|
'',
|
|
4553
|
-
'运行中按 Enter 可插入指示;Esc
|
|
4554
|
-
'↑/↓
|
|
4555
|
-
'
|
|
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。',
|
|
5491
|
+
'/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
|
|
4556
5492
|
].join('\n'),
|
|
4557
5493
|
});
|
|
4558
5494
|
break;
|
|
@@ -4605,22 +5541,32 @@ export class SshTui {
|
|
|
4605
5541
|
this.markDirty();
|
|
4606
5542
|
});
|
|
4607
5543
|
break;
|
|
5544
|
+
case 'find':
|
|
5545
|
+
this.runFindCommand(arg);
|
|
5546
|
+
break;
|
|
4608
5547
|
case 'clear':
|
|
4609
5548
|
this.rows.length = 0;
|
|
4610
5549
|
this.streaming = undefined;
|
|
4611
5550
|
this.streamingReasoning = undefined;
|
|
4612
5551
|
this.thinkingStartedAt = undefined;
|
|
4613
5552
|
this.focusedRow = null;
|
|
5553
|
+
this.searchHits = [];
|
|
5554
|
+
this.searchIndex = -1;
|
|
5555
|
+
this.searchQuery = '';
|
|
4614
5556
|
this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
|
|
4615
5557
|
break;
|
|
4616
5558
|
case 'status':
|
|
4617
5559
|
{
|
|
4618
5560
|
const plan = this.findLivePlanRow();
|
|
4619
5561
|
const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
|
|
5562
|
+
const provider = this.currentProviderId();
|
|
5563
|
+
const route = describeProviderRoute(provider);
|
|
5564
|
+
const model = this.selectionRef?.current?.model ?? this.agent.options.model ?? 'default';
|
|
5565
|
+
const effort = this.selectionRef?.current?.reasoningEffort;
|
|
4620
5566
|
const lines = [
|
|
4621
5567
|
`session: ${this.agent.id}`,
|
|
4622
|
-
`
|
|
4623
|
-
`provider: ${
|
|
5568
|
+
`route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
|
|
5569
|
+
`provider: ${route.kind}`,
|
|
4624
5570
|
`status: ${this.agent.status}`,
|
|
4625
5571
|
`preset: ${this.presetName}`,
|
|
4626
5572
|
`subagents: ${this.activeSubagents.size}`,
|
|
@@ -4674,7 +5620,7 @@ export class SshTui {
|
|
|
4674
5620
|
const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
|
|
4675
5621
|
return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
|
|
4676
5622
|
});
|
|
4677
|
-
this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n↑/↓
|
|
5623
|
+
this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n空输入时 ↑/↓ 选卡片,Enter 展开;Alt+3 跳到最新子代理。` });
|
|
4678
5624
|
}
|
|
4679
5625
|
break;
|
|
4680
5626
|
}
|