dsh-ssh-tui 0.1.5 → 0.1.7
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 +6 -0
- package/README.md +14 -4
- package/cordis.patch.yml +19 -0
- package/lib/index.js +56 -13
- package/lib/index.js.map +1 -1
- package/lib/picker.js +12 -6
- package/lib/picker.js.map +1 -1
- package/lib/session-list.js +42 -14
- package/lib/session-list.js.map +1 -1
- package/lib/startup.js +2 -2
- package/lib/startup.js.map +1 -1
- package/lib/subagent-model.js +64 -0
- package/lib/subagent-model.js.map +1 -0
- package/lib/tui.js +639 -127
- package/lib/tui.js.map +1 -1
- package/lib/types/session-list.d.ts +2 -10
- package/lib/types/subagent-model.d.ts +56 -0
- package/lib/types/tui.d.ts +40 -0
- package/package.json +3 -1
package/lib/tui.js
CHANGED
|
@@ -21,6 +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
25
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
25
26
|
const PROVIDER_TEMPLATES = {
|
|
26
27
|
official: {
|
|
@@ -62,6 +63,8 @@ const RENDER_INTERVAL_MS = 120;
|
|
|
62
63
|
const WAIT_INDICATOR_MS = 8000;
|
|
63
64
|
const STALL_WARNING_MS = 60000;
|
|
64
65
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
66
|
+
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
67
|
+
const SUBAGENT_DEFAULT_EFFORT_LABEL = '跟随提供商默认';
|
|
65
68
|
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
66
69
|
const MAX_TRANSCRIPT_ROWS = 5000;
|
|
67
70
|
const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -78,7 +81,12 @@ function displayDshPath(file) {
|
|
|
78
81
|
}
|
|
79
82
|
return `${home}\\${file}`.replaceAll('/', '\\');
|
|
80
83
|
}
|
|
81
|
-
|
|
84
|
+
const userHome = homedir();
|
|
85
|
+
if (home === userHome)
|
|
86
|
+
return `~/.dsh/${file}`;
|
|
87
|
+
if (home.startsWith(`${userHome}/`))
|
|
88
|
+
return `~/${home.slice(userHome.length + 1)}/${file}`;
|
|
89
|
+
return join(home, file);
|
|
82
90
|
}
|
|
83
91
|
const DSH_ENV_FILE = join(dshHomeDir(), IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
84
92
|
const DEEPSEEK_LOGO_VARIANTS = [
|
|
@@ -189,6 +197,8 @@ const DEEPSEEK_LOGO_VARIANTS = [
|
|
|
189
197
|
const LOCAL_COMMANDS = [
|
|
190
198
|
{ name: 'help', description: 'show all available commands' },
|
|
191
199
|
{ name: 'model', description: 'select model and reasoning effort (same provider)' },
|
|
200
|
+
{ name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
|
|
201
|
+
{ name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
|
|
192
202
|
{ name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
|
|
193
203
|
{ name: 'quit', description: 'exit the TUI' },
|
|
194
204
|
{ name: 'exit', description: 'exit the TUI' },
|
|
@@ -204,6 +214,12 @@ const LOCAL_COMMANDS = [
|
|
|
204
214
|
function displayWidth(text) {
|
|
205
215
|
let width = 0;
|
|
206
216
|
for (const char of text) {
|
|
217
|
+
if (char === '\t') {
|
|
218
|
+
// Tabs are expanded to spaces before rendering; keep the width
|
|
219
|
+
// calculation consistent with `sanitizeTerminalText()`.
|
|
220
|
+
width += 4;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
207
223
|
const cp = char.codePointAt(0) ?? 0;
|
|
208
224
|
const wide = (cp >= 0x1100 && cp <= 0x115f) ||
|
|
209
225
|
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
@@ -218,14 +234,25 @@ function displayWidth(text) {
|
|
|
218
234
|
}
|
|
219
235
|
return width;
|
|
220
236
|
}
|
|
237
|
+
/** Strip terminal control sequences and expand tabs for display output. */
|
|
238
|
+
function sanitizeTerminalText(text) {
|
|
239
|
+
return text
|
|
240
|
+
.replace(/[\x1b\u009b]/gu, '')
|
|
241
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
|
|
242
|
+
.replaceAll('\t', ' ');
|
|
243
|
+
}
|
|
244
|
+
/** UTF-16 length of the first code point, so fallback cuts never split a surrogate pair. */
|
|
245
|
+
function firstCodePointLength(text) {
|
|
246
|
+
return Array.from(text)[0]?.length ?? 1;
|
|
247
|
+
}
|
|
221
248
|
function wrap(text, width) {
|
|
222
249
|
const lines = [];
|
|
223
|
-
for (const
|
|
224
|
-
if (
|
|
250
|
+
for (const sourceLine of text.split('\n')) {
|
|
251
|
+
if (sourceLine === '') {
|
|
225
252
|
lines.push('');
|
|
226
253
|
continue;
|
|
227
254
|
}
|
|
228
|
-
let rest =
|
|
255
|
+
let rest = sanitizeTerminalText(sourceLine);
|
|
229
256
|
while (displayWidth(rest) > width) {
|
|
230
257
|
let cut = 0;
|
|
231
258
|
let used = 0;
|
|
@@ -237,7 +264,7 @@ function wrap(text, width) {
|
|
|
237
264
|
cut += char.length;
|
|
238
265
|
}
|
|
239
266
|
if (cut === 0)
|
|
240
|
-
cut =
|
|
267
|
+
cut = firstCodePointLength(rest);
|
|
241
268
|
lines.push(rest.slice(0, cut));
|
|
242
269
|
rest = rest.slice(cut);
|
|
243
270
|
}
|
|
@@ -247,9 +274,13 @@ function wrap(text, width) {
|
|
|
247
274
|
}
|
|
248
275
|
function truncate(text, maxLines) {
|
|
249
276
|
const lines = text.split('\n');
|
|
277
|
+
if (maxLines <= 0)
|
|
278
|
+
return '';
|
|
250
279
|
if (lines.length <= maxLines)
|
|
251
280
|
return text;
|
|
252
|
-
|
|
281
|
+
if (maxLines === 1)
|
|
282
|
+
return `… ${lines.length - 1} more line(s) …`;
|
|
283
|
+
const head = lines.slice(0, Math.max(0, maxLines - 2));
|
|
253
284
|
const tail = lines.slice(-1);
|
|
254
285
|
return [...head, `… ${lines.length - head.length - 1} more line(s) …`, ...tail].join('\n');
|
|
255
286
|
}
|
|
@@ -354,11 +385,12 @@ function markdownBaseCode(kind) {
|
|
|
354
385
|
}
|
|
355
386
|
/** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
|
|
356
387
|
function renderMarkdownBlockLine(block, color) {
|
|
388
|
+
const segments = block.segments.map(segment => ({ ...segment, text: sanitizeTerminalText(segment.text) }));
|
|
357
389
|
if (!color)
|
|
358
|
-
return
|
|
390
|
+
return segments.map(segment => segment.text).join('');
|
|
359
391
|
const base = markdownBaseCode(block.base);
|
|
360
392
|
let out = `\x1b[${base}m`;
|
|
361
|
-
for (const segment of
|
|
393
|
+
for (const segment of segments) {
|
|
362
394
|
const code = markdownSegmentCode(segment.kind);
|
|
363
395
|
if (code === '') {
|
|
364
396
|
out += segment.text;
|
|
@@ -402,7 +434,8 @@ function headingSegments(text, level) {
|
|
|
402
434
|
export function renderMarkdownLines(text, width, color) {
|
|
403
435
|
const lines = [];
|
|
404
436
|
let inFence = false;
|
|
405
|
-
for (const
|
|
437
|
+
for (const sourceLine of text.split('\n')) {
|
|
438
|
+
const raw = sanitizeTerminalText(sourceLine);
|
|
406
439
|
const fence = /^```([^\n]*)$/u.exec(raw.trim());
|
|
407
440
|
if (fence !== null) {
|
|
408
441
|
inFence = !inFence;
|
|
@@ -484,21 +517,27 @@ export function renderMarkdownLines(text, width, color) {
|
|
|
484
517
|
return lines;
|
|
485
518
|
}
|
|
486
519
|
/** Cut one line to fit a width, appending an ellipsis when truncated. */
|
|
487
|
-
function truncateToWidth(text, width) {
|
|
488
|
-
|
|
489
|
-
|
|
520
|
+
export function truncateToWidth(text, width) {
|
|
521
|
+
const safe = sanitizeTerminalText(text);
|
|
522
|
+
if (width <= 0)
|
|
523
|
+
return '';
|
|
524
|
+
if (displayWidth(safe) <= width)
|
|
525
|
+
return safe;
|
|
526
|
+
if (width === 1)
|
|
527
|
+
return '…';
|
|
528
|
+
const limit = width - 1;
|
|
490
529
|
let cut = 0;
|
|
491
530
|
let used = 0;
|
|
492
|
-
for (const char of
|
|
531
|
+
for (const char of safe) {
|
|
493
532
|
const charWidth = displayWidth(char);
|
|
494
|
-
if (used + charWidth >
|
|
533
|
+
if (used + charWidth > limit)
|
|
495
534
|
break;
|
|
496
535
|
used += charWidth;
|
|
497
536
|
cut += char.length;
|
|
498
537
|
}
|
|
499
538
|
if (cut === 0)
|
|
500
|
-
cut =
|
|
501
|
-
return `${
|
|
539
|
+
cut = firstCodePointLength(safe);
|
|
540
|
+
return `${safe.slice(0, cut)}…`;
|
|
502
541
|
}
|
|
503
542
|
/** Slice up to `maxWidth` display columns from the beginning of `text`. */
|
|
504
543
|
function forwardSliceByWidth(text, maxWidth) {
|
|
@@ -616,9 +655,11 @@ export function openCodeSourceFor(provider, llmPiAiSection) {
|
|
|
616
655
|
return null;
|
|
617
656
|
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
618
657
|
? profile.apiKeyEnv
|
|
619
|
-
: provider === 'opencode'
|
|
658
|
+
: provider === 'opencode'
|
|
620
659
|
? 'OPENCODE_API_KEY'
|
|
621
|
-
:
|
|
660
|
+
: provider === 'opencode-go'
|
|
661
|
+
? 'OPENCODE_GO_API_KEY'
|
|
662
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
622
663
|
const label = typeof profile?.displayName === 'string' && profile.displayName.trim() !== ''
|
|
623
664
|
? profile.displayName
|
|
624
665
|
: isGo ? 'OpenCode Go' : 'OpenCode Zen';
|
|
@@ -720,6 +761,8 @@ function isEscapePrefix(text) {
|
|
|
720
761
|
return false;
|
|
721
762
|
if (text === '\x1b[')
|
|
722
763
|
return true;
|
|
764
|
+
if (text === '\x1bO' || /^\x1bO[A-Z]?$/u.test(text))
|
|
765
|
+
return true;
|
|
723
766
|
if (/^\x1b\[[A-D]$/u.test(text))
|
|
724
767
|
return true;
|
|
725
768
|
if (/^\x1b\[[HF]$/u.test(text))
|
|
@@ -749,11 +792,23 @@ function scalarText(value) {
|
|
|
749
792
|
}
|
|
750
793
|
return null;
|
|
751
794
|
}
|
|
795
|
+
/** Take the first `max` code points of a string without splitting surrogates. */
|
|
796
|
+
function sliceCodePoints(text, max) {
|
|
797
|
+
if (max <= 0)
|
|
798
|
+
return '';
|
|
799
|
+
return Array.from(text).slice(0, max).join('');
|
|
800
|
+
}
|
|
801
|
+
/** Take the last `max` code points of a string without splitting surrogates. */
|
|
802
|
+
function lastCodePoints(text, max) {
|
|
803
|
+
if (max <= 0)
|
|
804
|
+
return '';
|
|
805
|
+
return Array.from(text).slice(-max).join('');
|
|
806
|
+
}
|
|
752
807
|
/** Prefer the fields a human scans for; fall back to the first scalar pairs. */
|
|
753
808
|
function friendlyArgsSummary(name, args) {
|
|
754
809
|
const parsed = parseJsonArgs(args);
|
|
755
810
|
if (parsed === null)
|
|
756
|
-
return args
|
|
811
|
+
return sliceCodePoints(args, 120);
|
|
757
812
|
const preferred = [
|
|
758
813
|
'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
|
|
759
814
|
'description', 'content', 'file_text', 'old_string', 'new_string',
|
|
@@ -779,7 +834,7 @@ function friendlyArgsSummary(name, args) {
|
|
|
779
834
|
}
|
|
780
835
|
}
|
|
781
836
|
const summary = parts.join(' ');
|
|
782
|
-
return summary === '' ? name : summary
|
|
837
|
+
return summary === '' ? name : sliceCodePoints(summary, 160);
|
|
783
838
|
}
|
|
784
839
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
785
840
|
const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
@@ -827,7 +882,7 @@ function diffHunksFromArgs(name, argsRaw) {
|
|
|
827
882
|
export function presentToolCall(name, args) {
|
|
828
883
|
const parsed = parseJsonArgs(args);
|
|
829
884
|
if (SHELL_TOOL_NAMES.has(name)) {
|
|
830
|
-
const command = typeof parsed?.command === 'string' ? parsed.command : args
|
|
885
|
+
const command = typeof parsed?.command === 'string' ? parsed.command : sliceCodePoints(args, 80);
|
|
831
886
|
return {
|
|
832
887
|
title: name,
|
|
833
888
|
summary: `$ ${command}`,
|
|
@@ -873,6 +928,17 @@ function diffContentLines(text) {
|
|
|
873
928
|
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
874
929
|
return body.split('\n');
|
|
875
930
|
}
|
|
931
|
+
/** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
|
|
932
|
+
function capDisplayLines(lines, maxLines) {
|
|
933
|
+
const budget = Math.max(1, Math.floor(maxLines));
|
|
934
|
+
if (lines.length <= budget)
|
|
935
|
+
return [...lines];
|
|
936
|
+
const omitted = lines.length - budget + 1;
|
|
937
|
+
const marker = { kind: 'tool-result', text: `… ${omitted} more line(s) …` };
|
|
938
|
+
if (budget === 1)
|
|
939
|
+
return [marker];
|
|
940
|
+
return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
|
|
941
|
+
}
|
|
876
942
|
/** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
|
|
877
943
|
export function renderToolDiff(diffs, maxLines) {
|
|
878
944
|
const rows = [];
|
|
@@ -901,15 +967,7 @@ export function renderToolDiff(diffs, maxLines) {
|
|
|
901
967
|
kind: 'tool-result',
|
|
902
968
|
text: `└ +${added} -${removed} · ${paths.size} file${paths.size === 1 ? '' : 's'}`,
|
|
903
969
|
});
|
|
904
|
-
|
|
905
|
-
return rows;
|
|
906
|
-
const head = rows.slice(0, Math.max(1, maxLines - 1));
|
|
907
|
-
const tail = rows.slice(-1);
|
|
908
|
-
return [
|
|
909
|
-
...head,
|
|
910
|
-
{ kind: 'tool-result', text: `… ${rows.length - head.length - 1} more line(s) …` },
|
|
911
|
-
...tail,
|
|
912
|
-
];
|
|
970
|
+
return capDisplayLines(rows, maxLines);
|
|
913
971
|
}
|
|
914
972
|
/** Keys whose multiline strings render as indented content blocks. */
|
|
915
973
|
const LONG_TEXT_KEYS = new Set([
|
|
@@ -917,6 +975,8 @@ const LONG_TEXT_KEYS = new Set([
|
|
|
917
975
|
'plan', 'markdown', 'details', 'description', 'text',
|
|
918
976
|
]);
|
|
919
977
|
const JSON_STRING_CAP = 400;
|
|
978
|
+
const JSON_MAX_DEPTH = 16;
|
|
979
|
+
const JSON_MAX_ENTRIES = 60;
|
|
920
980
|
/** Convert any parsed JSON value into readable indented display lines. */
|
|
921
981
|
export function friendlyJsonLines(value, depth = 0) {
|
|
922
982
|
const pad = ' '.repeat(depth);
|
|
@@ -929,11 +989,15 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
929
989
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
930
990
|
return [`${pad}${String(value)}`];
|
|
931
991
|
}
|
|
992
|
+
if (depth >= JSON_MAX_DEPTH) {
|
|
993
|
+
return [`${pad}…`];
|
|
994
|
+
}
|
|
932
995
|
if (Array.isArray(value)) {
|
|
933
996
|
if (value.length === 0)
|
|
934
997
|
return [`${pad}[]`];
|
|
998
|
+
const shown = value.slice(0, JSON_MAX_ENTRIES);
|
|
935
999
|
const lines = [];
|
|
936
|
-
for (const item of
|
|
1000
|
+
for (const item of shown) {
|
|
937
1001
|
if (item !== null && typeof item === 'object') {
|
|
938
1002
|
lines.push(`${pad}-`);
|
|
939
1003
|
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
@@ -942,14 +1006,17 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
942
1006
|
lines.push(`${pad}- ${friendlyJsonLines(item, 0)[0] ?? ''}`);
|
|
943
1007
|
}
|
|
944
1008
|
}
|
|
1009
|
+
if (value.length > shown.length)
|
|
1010
|
+
lines.push(`${pad}… ${value.length - shown.length} more item(s)`);
|
|
945
1011
|
return lines;
|
|
946
1012
|
}
|
|
947
1013
|
if (typeof value === 'object') {
|
|
948
1014
|
const entries = Object.entries(value);
|
|
949
1015
|
if (entries.length === 0)
|
|
950
1016
|
return [`${pad}{}`];
|
|
1017
|
+
const shown = entries.slice(0, JSON_MAX_ENTRIES);
|
|
951
1018
|
const lines = [];
|
|
952
|
-
for (const [key, item] of
|
|
1019
|
+
for (const [key, item] of shown) {
|
|
953
1020
|
if (typeof item === 'string' && item.includes('\n') && LONG_TEXT_KEYS.has(key)) {
|
|
954
1021
|
const contentLines = item.split('\n');
|
|
955
1022
|
lines.push(`${pad}${key}:`);
|
|
@@ -969,6 +1036,8 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
969
1036
|
lines.push(`${pad}${key}: ${scalar}`);
|
|
970
1037
|
}
|
|
971
1038
|
}
|
|
1039
|
+
if (entries.length > shown.length)
|
|
1040
|
+
lines.push(`${pad}… ${entries.length - shown.length} more field(s)`);
|
|
972
1041
|
return lines;
|
|
973
1042
|
}
|
|
974
1043
|
return [`${pad}${String(value)}`];
|
|
@@ -1031,7 +1100,7 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1031
1100
|
}
|
|
1032
1101
|
}
|
|
1033
1102
|
}
|
|
1034
|
-
return out;
|
|
1103
|
+
return capDisplayLines(out, maxLines);
|
|
1035
1104
|
}
|
|
1036
1105
|
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
1037
1106
|
export function parseExitStatus(text) {
|
|
@@ -1065,6 +1134,7 @@ export function formatDuration(ms) {
|
|
|
1065
1134
|
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1066
1135
|
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1067
1136
|
}
|
|
1137
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
1068
1138
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1069
1139
|
export class SshTui {
|
|
1070
1140
|
ctx;
|
|
@@ -1079,6 +1149,8 @@ export class SshTui {
|
|
|
1079
1149
|
historyIndex = -1;
|
|
1080
1150
|
status = 'idle';
|
|
1081
1151
|
dialog;
|
|
1152
|
+
dialogQueue = [];
|
|
1153
|
+
onboardingCompletion;
|
|
1082
1154
|
dirty = true;
|
|
1083
1155
|
disposed = false;
|
|
1084
1156
|
exiting = false;
|
|
@@ -1091,6 +1163,7 @@ export class SshTui {
|
|
|
1091
1163
|
resume;
|
|
1092
1164
|
providerName;
|
|
1093
1165
|
selectionRef;
|
|
1166
|
+
subagentSelection;
|
|
1094
1167
|
onSwitchSession;
|
|
1095
1168
|
onSelectionChanged;
|
|
1096
1169
|
resumePicker;
|
|
@@ -1108,6 +1181,7 @@ export class SshTui {
|
|
|
1108
1181
|
lastActivity = Date.now();
|
|
1109
1182
|
stalledWarningShown = false;
|
|
1110
1183
|
lastPaintAt = 0;
|
|
1184
|
+
commandAbort;
|
|
1111
1185
|
activeSubagents = new Map();
|
|
1112
1186
|
subagentSessions = new Set();
|
|
1113
1187
|
openToolCalls = new Map();
|
|
@@ -1133,6 +1207,7 @@ export class SshTui {
|
|
|
1133
1207
|
escapeTimer;
|
|
1134
1208
|
thinkingStartedAt;
|
|
1135
1209
|
completionSignaled = false;
|
|
1210
|
+
replaying = false;
|
|
1136
1211
|
completedAt = 0;
|
|
1137
1212
|
lastTitleUpdateAt = 0;
|
|
1138
1213
|
lastPaintRows = [];
|
|
@@ -1144,11 +1219,13 @@ export class SshTui {
|
|
|
1144
1219
|
this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
|
|
1145
1220
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
1146
1221
|
this.showReasoning = config.showReasoning !== false;
|
|
1147
|
-
this.goodbye =
|
|
1222
|
+
this.goodbye = config.goodbye
|
|
1223
|
+
?? this.ctx.get('tuiGoodbyeMessage')
|
|
1148
1224
|
?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
|
|
1149
1225
|
this.resume = config.resume === true;
|
|
1150
1226
|
this.providerName = config.provider ?? 'deepseek-official';
|
|
1151
1227
|
this.selectionRef = config.selectionRef;
|
|
1228
|
+
this.subagentSelection = config.subagentSelection ?? { current: { model: DEFAULT_SUBAGENT_MODEL } };
|
|
1152
1229
|
this.onSwitchSession = config.onSwitchSession;
|
|
1153
1230
|
this.onSelectionChanged = config.onSelectionChanged;
|
|
1154
1231
|
this.resumePicker = config.resumePicker === true;
|
|
@@ -1166,7 +1243,7 @@ export class SshTui {
|
|
|
1166
1243
|
process.stdin.on('data', this.handleData);
|
|
1167
1244
|
process.stdout.on('resize', this.markDirty);
|
|
1168
1245
|
process.on('SIGWINCH', this.markDirty);
|
|
1169
|
-
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
1246
|
+
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
1170
1247
|
const questions = this.ctx.get('userQuestions');
|
|
1171
1248
|
if (questions !== undefined) {
|
|
1172
1249
|
this.userQuestionDisposer = questions.registerProvider({ ask: this.handleUserQuestions });
|
|
@@ -1202,14 +1279,27 @@ export class SshTui {
|
|
|
1202
1279
|
}
|
|
1203
1280
|
}, RENDER_INTERVAL_MS);
|
|
1204
1281
|
this.renderTimer.unref?.();
|
|
1205
|
-
void this.maybeRunOnboarding()
|
|
1282
|
+
void this.maybeRunOnboarding().catch((error) => {
|
|
1283
|
+
if (this.disposed)
|
|
1284
|
+
return;
|
|
1285
|
+
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
1286
|
+
this.markDirty();
|
|
1287
|
+
});
|
|
1206
1288
|
}
|
|
1207
1289
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1208
1290
|
replayHistory() {
|
|
1209
|
-
|
|
1210
|
-
|
|
1291
|
+
this.replaying = true;
|
|
1292
|
+
try {
|
|
1293
|
+
for (const event of this.agent.session.events) {
|
|
1294
|
+
this.handleSessionEvent(this.agent.session, event);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
finally {
|
|
1298
|
+
this.replaying = false;
|
|
1211
1299
|
}
|
|
1212
1300
|
this.streaming = undefined;
|
|
1301
|
+
this.streamingReasoning = undefined;
|
|
1302
|
+
this.thinkingStartedAt = undefined;
|
|
1213
1303
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
1214
1304
|
this.dirty = true;
|
|
1215
1305
|
}
|
|
@@ -1222,6 +1312,8 @@ export class SshTui {
|
|
|
1222
1312
|
let stored = false;
|
|
1223
1313
|
if (credentials !== undefined) {
|
|
1224
1314
|
stored = (await credentials.describe(credentialRef(envRef))).configured;
|
|
1315
|
+
if (this.disposed)
|
|
1316
|
+
return;
|
|
1225
1317
|
}
|
|
1226
1318
|
if (!stored) {
|
|
1227
1319
|
// Belt-and-braces: the file provider may not have its in-memory snapshot
|
|
@@ -1230,6 +1322,8 @@ export class SshTui {
|
|
|
1230
1322
|
const credentialFile = join(dshHomeDir(), '.credentials.yaml');
|
|
1231
1323
|
if (existsSync(credentialFile)) {
|
|
1232
1324
|
const content = await readFile(credentialFile, 'utf8');
|
|
1325
|
+
if (this.disposed)
|
|
1326
|
+
return;
|
|
1233
1327
|
stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
|
|
1234
1328
|
}
|
|
1235
1329
|
}
|
|
@@ -1260,7 +1354,9 @@ export class SshTui {
|
|
|
1260
1354
|
}
|
|
1261
1355
|
/** Run the provider/API-key onboarding wizard. Resolves true when saved. */
|
|
1262
1356
|
runOnboarding() {
|
|
1263
|
-
|
|
1357
|
+
if (this.onboardingCompletion !== undefined)
|
|
1358
|
+
return this.onboardingCompletion;
|
|
1359
|
+
this.onboardingCompletion = new Promise((resolve) => {
|
|
1264
1360
|
this.onboarding = {
|
|
1265
1361
|
step: 'provider',
|
|
1266
1362
|
providerType: 'official',
|
|
@@ -1268,23 +1364,30 @@ export class SshTui {
|
|
|
1268
1364
|
baseUrl: '',
|
|
1269
1365
|
key: '',
|
|
1270
1366
|
models: [],
|
|
1271
|
-
|
|
1367
|
+
saving: false,
|
|
1368
|
+
resolve: (saved) => {
|
|
1369
|
+
this.onboardingCompletion = undefined;
|
|
1370
|
+
resolve(saved);
|
|
1371
|
+
},
|
|
1272
1372
|
};
|
|
1273
1373
|
this.input = '';
|
|
1274
1374
|
this.cursor = 0;
|
|
1275
1375
|
this.dialog = { kind: 'onboarding' };
|
|
1276
1376
|
this.markDirty();
|
|
1277
1377
|
});
|
|
1378
|
+
return this.onboardingCompletion;
|
|
1278
1379
|
}
|
|
1279
1380
|
cancelOnboarding() {
|
|
1280
1381
|
const state = this.onboarding;
|
|
1281
|
-
if (state === undefined)
|
|
1382
|
+
if (state === undefined || state.saving)
|
|
1282
1383
|
return;
|
|
1283
1384
|
this.onboarding = undefined;
|
|
1284
|
-
this.dialog
|
|
1385
|
+
if (this.dialog?.kind === 'onboarding')
|
|
1386
|
+
this.dialog = undefined;
|
|
1285
1387
|
this.input = '';
|
|
1286
1388
|
this.cursor = 0;
|
|
1287
1389
|
state.resolve(false);
|
|
1390
|
+
this.showNextDialog();
|
|
1288
1391
|
this.markDirty();
|
|
1289
1392
|
}
|
|
1290
1393
|
/** Restore the terminal, flush the session, and request process exit. */
|
|
@@ -1294,6 +1397,8 @@ export class SshTui {
|
|
|
1294
1397
|
this.disposed = true;
|
|
1295
1398
|
this.exiting = true;
|
|
1296
1399
|
const dialog = this.dialog;
|
|
1400
|
+
const queued = this.dialogQueue.splice(0);
|
|
1401
|
+
this.dialog = undefined;
|
|
1297
1402
|
if (dialog !== undefined) {
|
|
1298
1403
|
if (dialog.kind === 'confirm') {
|
|
1299
1404
|
dialog.resolve('cancel');
|
|
@@ -1304,10 +1409,23 @@ export class SshTui {
|
|
|
1304
1409
|
else {
|
|
1305
1410
|
this.cancelOnboarding();
|
|
1306
1411
|
}
|
|
1307
|
-
|
|
1412
|
+
}
|
|
1413
|
+
for (const pending of queued) {
|
|
1414
|
+
if (pending.kind === 'confirm') {
|
|
1415
|
+
pending.resolve('cancel');
|
|
1416
|
+
}
|
|
1417
|
+
else if (pending.kind === 'questions') {
|
|
1418
|
+
pending.reject(new UserQuestionError('TUI closed before the question was answered', 'ASK_ABORTED'));
|
|
1419
|
+
}
|
|
1308
1420
|
}
|
|
1309
1421
|
if (this.renderTimer !== undefined)
|
|
1310
1422
|
clearInterval(this.renderTimer);
|
|
1423
|
+
this.renderTimer = undefined;
|
|
1424
|
+
if (this.escapeTimer !== undefined)
|
|
1425
|
+
clearTimeout(this.escapeTimer);
|
|
1426
|
+
this.escapeTimer = undefined;
|
|
1427
|
+
this.commandAbort?.abort();
|
|
1428
|
+
this.commandAbort = undefined;
|
|
1311
1429
|
for (const dispose of this.disposers.splice(0)) {
|
|
1312
1430
|
dispose();
|
|
1313
1431
|
}
|
|
@@ -1331,7 +1449,7 @@ export class SshTui {
|
|
|
1331
1449
|
return;
|
|
1332
1450
|
this.exiting = true;
|
|
1333
1451
|
await this.dispose();
|
|
1334
|
-
process.stdout.write(`\n${this.goodbye}\n`);
|
|
1452
|
+
process.stdout.write(`\n${sanitizeTerminalText(this.goodbye)}\n`);
|
|
1335
1453
|
try {
|
|
1336
1454
|
await this.ctx.get('sessions')?.flush(this.agent.session);
|
|
1337
1455
|
}
|
|
@@ -1470,9 +1588,16 @@ export class SshTui {
|
|
|
1470
1588
|
if (row.kind === 'tool') {
|
|
1471
1589
|
const running = row.status === undefined || row.status === 'running';
|
|
1472
1590
|
const ok = row.status === 'ok';
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1591
|
+
// The status dot carries its own ANSI color. `styleLine` sanitizes its
|
|
1592
|
+
// input, so embedding the escape sequence there would leave literal
|
|
1593
|
+
// "[33m" text on screen; color the dot between two sanitized halves.
|
|
1594
|
+
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1595
|
+
const styleToolHeader = (line) => {
|
|
1596
|
+
const dotIndex = line.indexOf('●');
|
|
1597
|
+
if (dotColor === undefined || dotIndex === -1)
|
|
1598
|
+
return this.styleLine('tool', line);
|
|
1599
|
+
return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
|
|
1600
|
+
};
|
|
1476
1601
|
const state = running ? 'running…' : ok ? 'ok' : 'error';
|
|
1477
1602
|
const summary = row.summary === '' ? '' : ` ${row.summary}`;
|
|
1478
1603
|
const exit = !running && row.command !== undefined
|
|
@@ -1487,20 +1612,12 @@ export class SshTui {
|
|
|
1487
1612
|
const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
|
|
1488
1613
|
if (!row.expanded) {
|
|
1489
1614
|
const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
|
|
1490
|
-
const
|
|
1491
|
-
const withDot = dotIndex === -1
|
|
1492
|
-
? collapsed
|
|
1493
|
-
: `${collapsed.slice(0, dotIndex)}${dot}${collapsed.slice(dotIndex + 1)}`;
|
|
1494
|
-
const styled = this.styleLine('tool', withDot);
|
|
1615
|
+
const styled = styleToolHeader(collapsed);
|
|
1495
1616
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
1496
1617
|
continue;
|
|
1497
1618
|
}
|
|
1498
|
-
for (const
|
|
1499
|
-
|
|
1500
|
-
const withDot = dotIndex === -1
|
|
1501
|
-
? wrapped
|
|
1502
|
-
: `${wrapped.slice(0, dotIndex)}${dot}${wrapped.slice(dotIndex + 1)}`;
|
|
1503
|
-
addDisplay(this.styleLine('tool', withDot), row);
|
|
1619
|
+
for (const wrapped of wrap(plainHeader, width)) {
|
|
1620
|
+
addDisplay(styleToolHeader(wrapped), row);
|
|
1504
1621
|
}
|
|
1505
1622
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
1506
1623
|
for (const wrapped of wrap(line.text, Math.max(1, width - 2))) {
|
|
@@ -1600,7 +1717,7 @@ export class SshTui {
|
|
|
1600
1717
|
addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
|
|
1601
1718
|
addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
|
|
1602
1719
|
addDialog(` 模型: ${ob.models.join(', ')}`);
|
|
1603
|
-
addDialog(` API Key: ${ob.key
|
|
1720
|
+
addDialog(` API Key: ${sliceCodePoints(ob.key, 6)}…${lastCodePoints(ob.key, 4)}(长度 ${ob.key.length})`);
|
|
1604
1721
|
addDialog(' y = 保存, n = 重填, Esc = 取消');
|
|
1605
1722
|
break;
|
|
1606
1723
|
}
|
|
@@ -1615,13 +1732,14 @@ export class SshTui {
|
|
|
1615
1732
|
const options = d.question.options ?? [];
|
|
1616
1733
|
for (const [index, option] of options.entries()) {
|
|
1617
1734
|
const marker = d.selected.has(index) ? '●' : '○';
|
|
1735
|
+
const key = QUESTION_OPTION_KEYS[index] ?? '?';
|
|
1618
1736
|
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
1619
|
-
addDialog(` ${
|
|
1737
|
+
addDialog(` ${key} ${marker} ${option.label}${extra}`);
|
|
1620
1738
|
}
|
|
1621
1739
|
if (options.length === 0) {
|
|
1622
1740
|
addDialog(' (free text: type below and press Enter)');
|
|
1623
1741
|
}
|
|
1624
|
-
addDialog(` ${d.question.multiSelect === true ? 'digits toggle, Enter submit' : 'digit to select, Enter submit'}, Esc to cancel`);
|
|
1742
|
+
addDialog(` ${d.question.multiSelect === true ? 'digits/letters toggle, Enter submit' : 'digit/letter to select, Enter submit'}, Esc to cancel`);
|
|
1625
1743
|
}
|
|
1626
1744
|
}
|
|
1627
1745
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
@@ -1641,7 +1759,7 @@ export class SshTui {
|
|
|
1641
1759
|
for (const [index, command] of this.commandSuggestions.entries()) {
|
|
1642
1760
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
1643
1761
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
1644
|
-
suggestionLines.push(index === this.suggestionIndex
|
|
1762
|
+
suggestionLines.push(index === this.suggestionIndex && this.color
|
|
1645
1763
|
? `\x1b[7m${fitLine(line)}\x1b[27m`
|
|
1646
1764
|
: this.styleLine('system', fitLine(line)));
|
|
1647
1765
|
}
|
|
@@ -1717,6 +1835,12 @@ export class SshTui {
|
|
|
1717
1835
|
const statsText = this.statsText();
|
|
1718
1836
|
const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
|
|
1719
1837
|
let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
|
|
1838
|
+
const sub = this.subagentSelection.current;
|
|
1839
|
+
const subProvider = sub.provider ?? this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
1840
|
+
const subEffort = sub.reasoningEffort === undefined ? '' : `(${sub.reasoningEffort})`;
|
|
1841
|
+
statusText += sub.provider === undefined
|
|
1842
|
+
? ` · sub:${sub.model}${subEffort}`
|
|
1843
|
+
: ` · sub:${subProvider}/${sub.model}${subEffort}`;
|
|
1720
1844
|
if (inputView.folded)
|
|
1721
1845
|
statusText += ' · 输入已折叠 · Ctrl+T 展开';
|
|
1722
1846
|
else if (inputRows > 1)
|
|
@@ -1913,8 +2037,9 @@ export class SshTui {
|
|
|
1913
2037
|
this.paint();
|
|
1914
2038
|
};
|
|
1915
2039
|
styleLine(kind, text) {
|
|
2040
|
+
const safe = sanitizeTerminalText(text);
|
|
1916
2041
|
if (!this.color)
|
|
1917
|
-
return
|
|
2042
|
+
return safe;
|
|
1918
2043
|
const code = kind === 'user' ? '36' :
|
|
1919
2044
|
kind === 'assistant' ? '1;37' :
|
|
1920
2045
|
kind === 'reasoning' ? '2;3' :
|
|
@@ -1925,9 +2050,27 @@ export class SshTui {
|
|
|
1925
2050
|
kind === 'diff-path' ? '1;36' :
|
|
1926
2051
|
kind === 'error' ? '31' :
|
|
1927
2052
|
'90';
|
|
1928
|
-
return `\x1b[${code}m${
|
|
2053
|
+
return `\x1b[${code}m${safe}`;
|
|
1929
2054
|
}
|
|
1930
2055
|
// ── event handling ──────────────────────────────────────────────────────
|
|
2056
|
+
/**
|
|
2057
|
+
* Apply the TUI's subagent model selection to every child-agent request.
|
|
2058
|
+
* The parent request is left untouched (its own `/model` waterfall already
|
|
2059
|
+
* owns the route); direct children created by tool-subagent inherit the
|
|
2060
|
+
* parent provider unless `/submodel` stored an explicit subagent provider.
|
|
2061
|
+
*/
|
|
2062
|
+
handleAgentRequest = async ({ agent }, next) => {
|
|
2063
|
+
const resolved = await next();
|
|
2064
|
+
if (agent === this.agent)
|
|
2065
|
+
return resolved;
|
|
2066
|
+
const selection = this.subagentSelection.current;
|
|
2067
|
+
return {
|
|
2068
|
+
...resolved,
|
|
2069
|
+
...(selection.provider === undefined ? {} : { provider: selection.provider }),
|
|
2070
|
+
model: selection.model,
|
|
2071
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
|
|
2072
|
+
};
|
|
2073
|
+
};
|
|
1931
2074
|
handleSessionEvent = (session, event) => {
|
|
1932
2075
|
if (session.id !== this.agent.id) {
|
|
1933
2076
|
if (this.subagentSessions.has(session.id))
|
|
@@ -2116,6 +2259,9 @@ export class SshTui {
|
|
|
2116
2259
|
}
|
|
2117
2260
|
this.stats.steps += 1;
|
|
2118
2261
|
this.openStepStats = undefined;
|
|
2262
|
+
// Usage accounting is complete for this step; the map only exists to
|
|
2263
|
+
// deduplicate repeated usage reports during the step.
|
|
2264
|
+
this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
|
|
2119
2265
|
this.markDirty();
|
|
2120
2266
|
break;
|
|
2121
2267
|
}
|
|
@@ -2130,7 +2276,12 @@ export class SshTui {
|
|
|
2130
2276
|
this.pendingToolTimes.clear();
|
|
2131
2277
|
this.stalledWarningShown = false;
|
|
2132
2278
|
this.pendingMessages.clear();
|
|
2133
|
-
|
|
2279
|
+
// Aborted/errored turns may close without an assembled
|
|
2280
|
+
// assistant/message; never leave a half-streamed thinking block behind.
|
|
2281
|
+
this.streaming = undefined;
|
|
2282
|
+
this.streamingReasoning = undefined;
|
|
2283
|
+
this.thinkingStartedAt = undefined;
|
|
2284
|
+
if (reason.kind === 'completed' && !this.replaying && !this.completionSignaled) {
|
|
2134
2285
|
this.completionSignaled = true;
|
|
2135
2286
|
this.completedAt = Date.now();
|
|
2136
2287
|
this.updateTerminalTitle();
|
|
@@ -2215,7 +2366,7 @@ export class SshTui {
|
|
|
2215
2366
|
break;
|
|
2216
2367
|
}
|
|
2217
2368
|
case 'tool/call':
|
|
2218
|
-
this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${event.data.arguments
|
|
2369
|
+
this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${sliceCodePoints(event.data.arguments, 160)}` });
|
|
2219
2370
|
break;
|
|
2220
2371
|
case 'tool/result': {
|
|
2221
2372
|
const output = truncate(collectText(event.data.message.content), 4);
|
|
@@ -2260,16 +2411,23 @@ export class SshTui {
|
|
|
2260
2411
|
this.markDirty();
|
|
2261
2412
|
};
|
|
2262
2413
|
// ── approval and questions ──────────────────────────────────────────────
|
|
2263
|
-
handleApproval = async (request,
|
|
2414
|
+
handleApproval = async (request, _next) => {
|
|
2264
2415
|
const agentLabel = request.agent.id === this.agent.id
|
|
2265
2416
|
? '当前会话'
|
|
2266
2417
|
: `子代理 ${request.agent.id}`;
|
|
2267
2418
|
return new Promise((resolve) => {
|
|
2419
|
+
if (request.signal?.aborted === true) {
|
|
2420
|
+
resolve('cancelled');
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
let dialog;
|
|
2268
2424
|
const onAbort = () => {
|
|
2269
|
-
|
|
2425
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2426
|
+
if (dialog !== undefined)
|
|
2427
|
+
this.abortConfirm(dialog);
|
|
2270
2428
|
};
|
|
2271
2429
|
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2272
|
-
this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
|
|
2430
|
+
dialog = this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
|
|
2273
2431
|
request.signal?.removeEventListener('abort', onAbort);
|
|
2274
2432
|
resolve(answer === 'y' ? 'allowed-once' : answer === 'n' ? 'rejected' : 'cancelled');
|
|
2275
2433
|
});
|
|
@@ -2282,54 +2440,114 @@ export class SshTui {
|
|
|
2282
2440
|
: `子代理 ${request.agent.id}`;
|
|
2283
2441
|
for (const [index, question] of request.questions.entries()) {
|
|
2284
2442
|
const answer = await new Promise((resolve, reject) => {
|
|
2443
|
+
const fail = (error) => {
|
|
2444
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2445
|
+
reject(error);
|
|
2446
|
+
};
|
|
2285
2447
|
const onAbort = () => {
|
|
2286
|
-
|
|
2287
|
-
|
|
2448
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2449
|
+
if (dialog !== undefined) {
|
|
2450
|
+
dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
2451
|
+
}
|
|
2452
|
+
else {
|
|
2453
|
+
reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
2454
|
+
}
|
|
2288
2455
|
};
|
|
2456
|
+
let dialog;
|
|
2289
2457
|
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2458
|
+
if (request.signal?.aborted === true) {
|
|
2459
|
+
onAbort();
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2290
2462
|
const labeled = agentLabel === undefined
|
|
2291
2463
|
? question
|
|
2292
2464
|
: { ...question, question: `[${agentLabel}] ${question.question}` };
|
|
2293
|
-
this.openQuestion(labeled, index, request.questions.length, (selection) => {
|
|
2465
|
+
dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
|
|
2294
2466
|
request.signal?.removeEventListener('abort', onAbort);
|
|
2295
2467
|
resolve(selection);
|
|
2296
|
-
},
|
|
2468
|
+
}, fail);
|
|
2297
2469
|
});
|
|
2298
2470
|
answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
|
|
2299
2471
|
}
|
|
2300
2472
|
return { answers };
|
|
2301
2473
|
};
|
|
2302
|
-
|
|
2303
|
-
|
|
2474
|
+
/** Queue one dialog behind an already-open one instead of overwriting it. */
|
|
2475
|
+
openDialog(dialog) {
|
|
2476
|
+
if (this.dialog === undefined) {
|
|
2477
|
+
this.dialog = dialog;
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
this.dialogQueue.push(dialog);
|
|
2481
|
+
}
|
|
2482
|
+
this.markDirty();
|
|
2483
|
+
}
|
|
2484
|
+
showNextDialog() {
|
|
2485
|
+
if (this.dialog !== undefined)
|
|
2486
|
+
return;
|
|
2487
|
+
const next = this.dialogQueue.shift();
|
|
2488
|
+
if (next !== undefined) {
|
|
2489
|
+
this.dialog = next;
|
|
2490
|
+
this.markDirty();
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
removeQueuedDialog(dialog) {
|
|
2494
|
+
const index = this.dialogQueue.indexOf(dialog);
|
|
2495
|
+
if (index !== -1)
|
|
2496
|
+
this.dialogQueue.splice(index, 1);
|
|
2497
|
+
}
|
|
2498
|
+
settleQuestion(dialog, finish) {
|
|
2499
|
+
if (this.dialog === dialog) {
|
|
2500
|
+
this.dialog = undefined;
|
|
2501
|
+
}
|
|
2502
|
+
else {
|
|
2503
|
+
this.removeQueuedDialog(dialog);
|
|
2504
|
+
}
|
|
2505
|
+
finish();
|
|
2506
|
+
this.showNextDialog();
|
|
2304
2507
|
this.markDirty();
|
|
2305
2508
|
}
|
|
2509
|
+
openConfirm(prompt, hint, resolve) {
|
|
2510
|
+
const dialog = { kind: 'confirm', prompt, hint, resolve };
|
|
2511
|
+
this.openDialog(dialog);
|
|
2512
|
+
return dialog;
|
|
2513
|
+
}
|
|
2306
2514
|
closeConfirm(value) {
|
|
2307
2515
|
const dialog = this.dialog;
|
|
2308
2516
|
if (dialog === undefined || dialog.kind !== 'confirm')
|
|
2309
2517
|
return;
|
|
2310
2518
|
this.dialog = undefined;
|
|
2311
2519
|
dialog.resolve(value);
|
|
2520
|
+
this.showNextDialog();
|
|
2312
2521
|
this.markDirty();
|
|
2313
2522
|
}
|
|
2523
|
+
/** Resolve one queued or active confirm from its abort signal. */
|
|
2524
|
+
abortConfirm(dialog) {
|
|
2525
|
+
if (this.dialog === dialog) {
|
|
2526
|
+
this.dialog = undefined;
|
|
2527
|
+
dialog.resolve('cancel');
|
|
2528
|
+
this.showNextDialog();
|
|
2529
|
+
this.markDirty();
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
this.removeQueuedDialog(dialog);
|
|
2533
|
+
dialog.resolve('cancel');
|
|
2534
|
+
}
|
|
2314
2535
|
openQuestion(question, index, total, resolve, reject) {
|
|
2315
|
-
|
|
2536
|
+
const dialog = {
|
|
2316
2537
|
kind: 'questions',
|
|
2317
2538
|
question,
|
|
2318
2539
|
index,
|
|
2319
2540
|
total,
|
|
2320
2541
|
selected: new Set(),
|
|
2321
2542
|
resolve: (selection) => {
|
|
2322
|
-
this.dialog
|
|
2323
|
-
resolve(selection);
|
|
2324
|
-
this.markDirty();
|
|
2543
|
+
this.settleQuestion(dialog, () => resolve(selection));
|
|
2325
2544
|
},
|
|
2326
2545
|
reject: (error) => {
|
|
2327
|
-
this.dialog
|
|
2328
|
-
reject(error);
|
|
2329
|
-
this.markDirty();
|
|
2546
|
+
this.settleQuestion(dialog, () => reject(error));
|
|
2330
2547
|
},
|
|
2331
2548
|
};
|
|
2332
|
-
this.
|
|
2549
|
+
this.openDialog(dialog);
|
|
2550
|
+
return dialog;
|
|
2333
2551
|
}
|
|
2334
2552
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
2335
2553
|
askQuestion(question, index = 0, total = 1) {
|
|
@@ -2359,7 +2577,9 @@ export class SshTui {
|
|
|
2359
2577
|
* registry, while the TUI wants the endpoint's current list.
|
|
2360
2578
|
*/
|
|
2361
2579
|
async discoverEndpointModels(provider) {
|
|
2580
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
2362
2581
|
const profile = this.piAiProviderProfile(provider);
|
|
2582
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
2363
2583
|
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
2364
2584
|
? profile.baseURL.trim()
|
|
2365
2585
|
: this.openCodeListingBaseURL(provider);
|
|
@@ -2368,7 +2588,7 @@ export class SshTui {
|
|
|
2368
2588
|
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
2369
2589
|
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
2370
2590
|
? profile.apiKeyEnv.trim()
|
|
2371
|
-
:
|
|
2591
|
+
: source?.apiKeyEnv;
|
|
2372
2592
|
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
2373
2593
|
const llm = this.ctx.get('llm');
|
|
2374
2594
|
if (llm === undefined)
|
|
@@ -2561,6 +2781,147 @@ export class SshTui {
|
|
|
2561
2781
|
this.pushRow({ kind: 'system', text: `模型已切换:${selected.id}(思考强度 ${effort ?? '默认'});下一步请求生效。` });
|
|
2562
2782
|
this.markDirty();
|
|
2563
2783
|
}
|
|
2784
|
+
/** Provider route the next subagent request should use. */
|
|
2785
|
+
effectiveSubagentProvider() {
|
|
2786
|
+
return this.subagentSelection.current.provider
|
|
2787
|
+
?? this.selectionRef?.current?.provider
|
|
2788
|
+
?? this.agent.options.provider
|
|
2789
|
+
?? this.providerName;
|
|
2790
|
+
}
|
|
2791
|
+
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
2792
|
+
async saveSubagentSelection(next) {
|
|
2793
|
+
this.subagentSelection.current = next;
|
|
2794
|
+
const settings = this.ctx.get('settings');
|
|
2795
|
+
if (settings === undefined) {
|
|
2796
|
+
this.pushRow({ kind: 'error', text: '设置服务不可用,子代理选择仅当前会话生效。' });
|
|
2797
|
+
this.markDirty();
|
|
2798
|
+
return false;
|
|
2799
|
+
}
|
|
2800
|
+
await settings.replace(SUBAGENT_SETTINGS_NAMESPACE, subagentSettingsValue(next));
|
|
2801
|
+
return true;
|
|
2802
|
+
}
|
|
2803
|
+
/** Resolve the picker model list for one provider (endpoint first, then catalog). */
|
|
2804
|
+
async subagentModelOptions(provider) {
|
|
2805
|
+
const llm = this.ctx.get('llm');
|
|
2806
|
+
let options = [];
|
|
2807
|
+
let source = '已配置列表';
|
|
2808
|
+
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
2809
|
+
const previousStatus = this.status;
|
|
2810
|
+
try {
|
|
2811
|
+
this.status = `正在从端点获取子代理模型列表(${provider})…`;
|
|
2812
|
+
this.markDirty();
|
|
2813
|
+
options = await this.discoverEndpointModels(provider);
|
|
2814
|
+
if (options.length > 0) {
|
|
2815
|
+
source = '端点实时列表';
|
|
2816
|
+
try {
|
|
2817
|
+
const listed = (await llm?.listModels(provider)) ?? [];
|
|
2818
|
+
const endpointIds = new Set(options.map(model => model.id));
|
|
2819
|
+
for (const model of listed) {
|
|
2820
|
+
if (!endpointIds.has(model.id))
|
|
2821
|
+
options.push({ id: model.id, label: model.name || model.id });
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
catch {
|
|
2825
|
+
// The endpoint list stands alone when the catalog cannot be read.
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
catch {
|
|
2830
|
+
options = [];
|
|
2831
|
+
}
|
|
2832
|
+
finally {
|
|
2833
|
+
this.status = previousStatus;
|
|
2834
|
+
this.markDirty();
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
if (options.length === 0) {
|
|
2838
|
+
try {
|
|
2839
|
+
const listed = (await llm?.listModels(provider)) ?? [];
|
|
2840
|
+
options = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
2841
|
+
}
|
|
2842
|
+
catch {
|
|
2843
|
+
options = [];
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
return { options, source };
|
|
2847
|
+
}
|
|
2848
|
+
/** /submodel: pick (or set) the model subagent children use. */
|
|
2849
|
+
async runSubmodelCommand(arg) {
|
|
2850
|
+
const provider = this.effectiveSubagentProvider();
|
|
2851
|
+
const current = this.subagentSelection.current;
|
|
2852
|
+
const direct = arg.trim();
|
|
2853
|
+
let selectedId = direct;
|
|
2854
|
+
if (selectedId === '') {
|
|
2855
|
+
const { options, source } = await this.subagentModelOptions(provider);
|
|
2856
|
+
if (options.length === 0) {
|
|
2857
|
+
options.push({ id: current.model, label: current.model });
|
|
2858
|
+
}
|
|
2859
|
+
const selected = await this.pickModelOption(options, provider, source, current.model);
|
|
2860
|
+
if (selected === undefined)
|
|
2861
|
+
return;
|
|
2862
|
+
selectedId = selected.id;
|
|
2863
|
+
}
|
|
2864
|
+
if (!(await this.ensureProviderModelConfigured(provider, selectedId)))
|
|
2865
|
+
return;
|
|
2866
|
+
const persisted = await this.saveSubagentSelection({ ...current, model: selectedId });
|
|
2867
|
+
this.pushRow({
|
|
2868
|
+
kind: 'system',
|
|
2869
|
+
text: `${current.provider === undefined
|
|
2870
|
+
? `子代理模型已切换:${selectedId}(提供方跟随父会话 ${provider})。`
|
|
2871
|
+
: `子代理模型已切换:${selectedId}(提供方 ${provider})。`}${persisted ? '' : '(仅当前会话)'}`,
|
|
2872
|
+
});
|
|
2873
|
+
this.markDirty();
|
|
2874
|
+
}
|
|
2875
|
+
/** /subeffort: pick the reasoning effort subagent children use. */
|
|
2876
|
+
async runSubeffortCommand() {
|
|
2877
|
+
const provider = this.effectiveSubagentProvider();
|
|
2878
|
+
const current = this.subagentSelection.current;
|
|
2879
|
+
const llm = this.ctx.get('llm');
|
|
2880
|
+
let effortOptions = [];
|
|
2881
|
+
try {
|
|
2882
|
+
const info = await llm?.resolveModelInfo(provider, current.model);
|
|
2883
|
+
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
2884
|
+
}
|
|
2885
|
+
catch {
|
|
2886
|
+
effortOptions = [];
|
|
2887
|
+
}
|
|
2888
|
+
if (effortOptions.length === 0) {
|
|
2889
|
+
effortOptions = ['off', 'high', 'max'].map(id => ({ id, label: id }));
|
|
2890
|
+
}
|
|
2891
|
+
const choices = [
|
|
2892
|
+
{ id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL },
|
|
2893
|
+
...effortOptions.map(option => ({ id: option.id, label: option.label })),
|
|
2894
|
+
];
|
|
2895
|
+
const answer = await this.askQuestion({
|
|
2896
|
+
id: 'subagent-effort-pick',
|
|
2897
|
+
question: `选择子代理思考强度(${provider}/${current.model})`,
|
|
2898
|
+
options: choices.map(option => ({
|
|
2899
|
+
label: option.label,
|
|
2900
|
+
description: option.id === undefined
|
|
2901
|
+
? '清空自定义强度,跟随提供商/模型默认'
|
|
2902
|
+
: option.id === String(current.reasoningEffort)
|
|
2903
|
+
? '当前'
|
|
2904
|
+
: undefined,
|
|
2905
|
+
})),
|
|
2906
|
+
});
|
|
2907
|
+
const picked = choices.find(option => option.label === answer.selected[0]);
|
|
2908
|
+
if (picked === undefined)
|
|
2909
|
+
return;
|
|
2910
|
+
const next = {
|
|
2911
|
+
...current,
|
|
2912
|
+
...(picked.id === undefined
|
|
2913
|
+
? { reasoningEffort: undefined }
|
|
2914
|
+
: { reasoningEffort: ReasoningEffortId(picked.id) }),
|
|
2915
|
+
};
|
|
2916
|
+
const persisted = await this.saveSubagentSelection(next);
|
|
2917
|
+
this.pushRow({
|
|
2918
|
+
kind: 'system',
|
|
2919
|
+
text: `${picked.id === undefined
|
|
2920
|
+
? '子代理思考强度已恢复为提供商默认。'
|
|
2921
|
+
: `子代理思考强度已切换:${picked.id}。`}${persisted ? '' : '(仅当前会话)'}`,
|
|
2922
|
+
});
|
|
2923
|
+
this.markDirty();
|
|
2924
|
+
}
|
|
2564
2925
|
/** /mode: pick an agent preset (standard / minimal / code / cordis / routing-suite / ...). */
|
|
2565
2926
|
async runModeCommand() {
|
|
2566
2927
|
const agentPresets = this.ctx.get('agentPresets');
|
|
@@ -2617,9 +2978,14 @@ export class SshTui {
|
|
|
2617
2978
|
this.markDirty();
|
|
2618
2979
|
return;
|
|
2619
2980
|
}
|
|
2981
|
+
if (this.onSwitchSession === undefined) {
|
|
2982
|
+
this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume。' });
|
|
2983
|
+
this.markDirty();
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2620
2986
|
this.pushRow({ kind: 'system', text: `正在切换到会话 ${target}…` });
|
|
2621
2987
|
this.markDirty();
|
|
2622
|
-
await this.onSwitchSession
|
|
2988
|
+
await this.onSwitchSession(target);
|
|
2623
2989
|
return;
|
|
2624
2990
|
}
|
|
2625
2991
|
const persistence = this.ctx.get('sessionPersistence');
|
|
@@ -2639,15 +3005,20 @@ export class SshTui {
|
|
|
2639
3005
|
question: '选择要恢复的历史会话',
|
|
2640
3006
|
options: inspected.map(item => ({
|
|
2641
3007
|
label: item.label,
|
|
2642
|
-
description: `${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
|
|
3008
|
+
description: `${item.unreadable === true ? '⚠ 无法读取 · ' : ''}${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
|
|
2643
3009
|
})),
|
|
2644
3010
|
});
|
|
2645
3011
|
const picked = inspected.find(item => item.label === answer.selected[0]);
|
|
2646
3012
|
if (picked === undefined)
|
|
2647
3013
|
return;
|
|
3014
|
+
if (this.onSwitchSession === undefined) {
|
|
3015
|
+
this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume。' });
|
|
3016
|
+
this.markDirty();
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
2648
3019
|
this.pushRow({ kind: 'system', text: `正在切换到会话 ${picked.id}…` });
|
|
2649
3020
|
this.markDirty();
|
|
2650
|
-
await this.onSwitchSession
|
|
3021
|
+
await this.onSwitchSession(picked.id);
|
|
2651
3022
|
}
|
|
2652
3023
|
/** Current provider route selected for the running agent. */
|
|
2653
3024
|
currentProvider() {
|
|
@@ -2850,6 +3221,13 @@ export class SshTui {
|
|
|
2850
3221
|
this.deleteAtCursor();
|
|
2851
3222
|
return;
|
|
2852
3223
|
}
|
|
3224
|
+
const ss3 = /^\x1bO[A-Z]/u.exec(combined);
|
|
3225
|
+
if (ss3 !== null) {
|
|
3226
|
+
const rest = combined.slice(ss3[0].length);
|
|
3227
|
+
if (rest !== '')
|
|
3228
|
+
this.handlePlainText(rest);
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
2853
3231
|
if (isEscapePrefix(combined)) {
|
|
2854
3232
|
this.escapeBuffer = combined;
|
|
2855
3233
|
this.escapeTimer = setTimeout(() => {
|
|
@@ -2859,7 +3237,12 @@ export class SshTui {
|
|
|
2859
3237
|
if (pending === '\x1b') {
|
|
2860
3238
|
this.handleChar('\x1b');
|
|
2861
3239
|
}
|
|
2862
|
-
else if (pending
|
|
3240
|
+
else if (pending === '\x1bO') {
|
|
3241
|
+
// ESC O without an SS3 final byte is an Alt+O keystroke, not a
|
|
3242
|
+
// function key.
|
|
3243
|
+
this.handlePlainText('O');
|
|
3244
|
+
}
|
|
3245
|
+
else if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO')) {
|
|
2863
3246
|
// An escape sequence that never completed: consume it silently
|
|
2864
3247
|
// instead of treating its ESC byte as a cancel.
|
|
2865
3248
|
}
|
|
@@ -2869,15 +3252,15 @@ export class SshTui {
|
|
|
2869
3252
|
}, 60);
|
|
2870
3253
|
return;
|
|
2871
3254
|
}
|
|
2872
|
-
if (combined.startsWith('\x1b[')) {
|
|
2873
|
-
// Unknown escape sequence
|
|
3255
|
+
if (combined.startsWith('\x1b[') || combined.startsWith('\x1bO')) {
|
|
3256
|
+
// Unknown escape sequence (including SS3 function keys) — consume
|
|
3257
|
+
// without side effects.
|
|
2874
3258
|
return;
|
|
2875
3259
|
}
|
|
2876
|
-
if (combined.startsWith('\x1b')) {
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
this.handlePlainText(rest);
|
|
3260
|
+
if (combined.startsWith('\x1b') && combined.length > 1) {
|
|
3261
|
+
// Alt+<key> sequences: ignore the ESC half instead of triggering cancel,
|
|
3262
|
+
// and type the printable remainder.
|
|
3263
|
+
this.handlePlainText(combined.slice(1));
|
|
2881
3264
|
return;
|
|
2882
3265
|
}
|
|
2883
3266
|
this.handlePlainText(combined);
|
|
@@ -3055,23 +3438,20 @@ export class SshTui {
|
|
|
3055
3438
|
this.closeConfirm('cancel');
|
|
3056
3439
|
return;
|
|
3057
3440
|
}
|
|
3058
|
-
const
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
if (
|
|
3062
|
-
if (dialog.
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
else
|
|
3066
|
-
dialog.selected.add(index);
|
|
3067
|
-
}
|
|
3068
|
-
else {
|
|
3069
|
-
dialog.selected.clear();
|
|
3441
|
+
const key = text.toLowerCase();
|
|
3442
|
+
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
3443
|
+
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
3444
|
+
if (dialog.question.multiSelect === true) {
|
|
3445
|
+
if (dialog.selected.has(index))
|
|
3446
|
+
dialog.selected.delete(index);
|
|
3447
|
+
else
|
|
3070
3448
|
dialog.selected.add(index);
|
|
3071
|
-
}
|
|
3072
|
-
this.markDirty();
|
|
3073
3449
|
}
|
|
3074
|
-
|
|
3450
|
+
else {
|
|
3451
|
+
dialog.selected.clear();
|
|
3452
|
+
dialog.selected.add(index);
|
|
3453
|
+
}
|
|
3454
|
+
this.markDirty();
|
|
3075
3455
|
}
|
|
3076
3456
|
if (text === '\r' || text === '\n') {
|
|
3077
3457
|
const options = dialog.question.options ?? [];
|
|
@@ -3187,8 +3567,10 @@ export class SshTui {
|
|
|
3187
3567
|
return;
|
|
3188
3568
|
}
|
|
3189
3569
|
case 'confirm':
|
|
3570
|
+
if (state.saving)
|
|
3571
|
+
return;
|
|
3190
3572
|
if (text === 'y' || text === 'Y') {
|
|
3191
|
-
|
|
3573
|
+
state.saving = true;
|
|
3192
3574
|
this.input = '';
|
|
3193
3575
|
this.cursor = 0;
|
|
3194
3576
|
void this.saveOnboarding();
|
|
@@ -3345,7 +3727,10 @@ export class SshTui {
|
|
|
3345
3727
|
]);
|
|
3346
3728
|
this.pushRow({ kind: 'system', text: `提供商 ${state.providerId} 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
3347
3729
|
}
|
|
3348
|
-
|
|
3730
|
+
// Only store the key when its provider profile actually made it to
|
|
3731
|
+
// settings; otherwise the saved key points at an unusable route.
|
|
3732
|
+
if (saved)
|
|
3733
|
+
await this.saveCredential(credentials, envRef, state.key);
|
|
3349
3734
|
if (saved) {
|
|
3350
3735
|
const selection = {
|
|
3351
3736
|
provider: state.providerId,
|
|
@@ -3370,7 +3755,10 @@ export class SshTui {
|
|
|
3370
3755
|
}
|
|
3371
3756
|
finally {
|
|
3372
3757
|
this.onboarding = undefined;
|
|
3758
|
+
if (this.dialog?.kind === 'onboarding')
|
|
3759
|
+
this.dialog = undefined;
|
|
3373
3760
|
state.resolve(saved);
|
|
3761
|
+
this.showNextDialog();
|
|
3374
3762
|
this.markDirty();
|
|
3375
3763
|
}
|
|
3376
3764
|
}
|
|
@@ -3398,22 +3786,52 @@ export class SshTui {
|
|
|
3398
3786
|
const home = dshHomeDir();
|
|
3399
3787
|
const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
3400
3788
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
3789
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
3401
3790
|
if (IS_WINDOWS) {
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3791
|
+
let previous = '';
|
|
3792
|
+
try {
|
|
3793
|
+
previous = await readFile(file, 'utf8');
|
|
3405
3794
|
}
|
|
3795
|
+
catch {
|
|
3796
|
+
// File absent: start fresh below.
|
|
3797
|
+
}
|
|
3798
|
+
const preserved = previous.split(/\r?\n/u).filter(Boolean).filter(line => {
|
|
3799
|
+
if (/^@echo off$/iu.test(line.trim()))
|
|
3800
|
+
return false;
|
|
3801
|
+
if (/^rem Generated by dsh-ssh-tui onboarding\.$/iu.test(line.trim()))
|
|
3802
|
+
return false;
|
|
3803
|
+
for (const name of Object.keys(entries)) {
|
|
3804
|
+
if (new RegExp(`^set\\s+"?${escapeRegex(name)}"?=`, 'iu').test(line.trim()))
|
|
3805
|
+
return false;
|
|
3806
|
+
}
|
|
3807
|
+
return true;
|
|
3808
|
+
});
|
|
3809
|
+
const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
|
|
3810
|
+
const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
|
|
3406
3811
|
await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
|
|
3407
3812
|
// Persist for future processes; best-effort, env.cmd remains as a manual fallback.
|
|
3408
3813
|
await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
|
|
3409
3814
|
return;
|
|
3410
3815
|
}
|
|
3411
3816
|
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3817
|
+
let previous = '';
|
|
3818
|
+
try {
|
|
3819
|
+
previous = await readFile(file, 'utf8');
|
|
3820
|
+
}
|
|
3821
|
+
catch {
|
|
3822
|
+
// File absent: start fresh below.
|
|
3415
3823
|
}
|
|
3416
|
-
|
|
3824
|
+
const preserved = previous.split('\n').filter(Boolean).filter(line => {
|
|
3825
|
+
if (line.trim() === '# Generated by dsh-ssh-tui onboarding.')
|
|
3826
|
+
return false;
|
|
3827
|
+
for (const name of Object.keys(entries)) {
|
|
3828
|
+
if (new RegExp(`^export\\s+${escapeRegex(name)}=`).test(line))
|
|
3829
|
+
return false;
|
|
3830
|
+
}
|
|
3831
|
+
return true;
|
|
3832
|
+
});
|
|
3833
|
+
const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
|
|
3834
|
+
await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
|
|
3417
3835
|
await this.ensurePosixEnvHook();
|
|
3418
3836
|
}
|
|
3419
3837
|
/** Persist one variable into the Windows user environment (best-effort). */
|
|
@@ -3426,7 +3844,9 @@ export class SshTui {
|
|
|
3426
3844
|
}
|
|
3427
3845
|
/** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
|
|
3428
3846
|
async ensurePosixEnvHook() {
|
|
3429
|
-
const
|
|
3847
|
+
const envFile = join(dshHomeDir(), 'env.sh');
|
|
3848
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3849
|
+
const sourceLine = `[ -f ${quote(envFile)} ] && . ${quote(envFile)}`;
|
|
3430
3850
|
const marker = '# dsh-ssh-tui launch environment';
|
|
3431
3851
|
const shell = process.env.SHELL ?? '';
|
|
3432
3852
|
const targets = [];
|
|
@@ -3449,7 +3869,7 @@ export class SshTui {
|
|
|
3449
3869
|
if (content.includes(marker))
|
|
3450
3870
|
continue;
|
|
3451
3871
|
const line = relative.endsWith('config.fish')
|
|
3452
|
-
?
|
|
3872
|
+
? `test -f ${quote(envFile)}; and source ${quote(envFile)}`
|
|
3453
3873
|
: sourceLine;
|
|
3454
3874
|
const addition = `${content === '' ? '' : '\n'}${marker}\n${line}\n`;
|
|
3455
3875
|
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
@@ -3595,6 +4015,28 @@ export class SshTui {
|
|
|
3595
4015
|
this.markDirty();
|
|
3596
4016
|
});
|
|
3597
4017
|
break;
|
|
4018
|
+
case 'submodel':
|
|
4019
|
+
void this.runSubmodelCommand(arg).catch((error) => {
|
|
4020
|
+
if (error instanceof UserQuestionError) {
|
|
4021
|
+
this.pushRow({ kind: 'system', text: '子代理模型选择已取消。' });
|
|
4022
|
+
}
|
|
4023
|
+
else {
|
|
4024
|
+
this.pushRow({ kind: 'error', text: `/submodel failed: ${errorChain(error)}` });
|
|
4025
|
+
}
|
|
4026
|
+
this.markDirty();
|
|
4027
|
+
});
|
|
4028
|
+
break;
|
|
4029
|
+
case 'subeffort':
|
|
4030
|
+
void this.runSubeffortCommand().catch((error) => {
|
|
4031
|
+
if (error instanceof UserQuestionError) {
|
|
4032
|
+
this.pushRow({ kind: 'system', text: '子代理思考强度选择已取消。' });
|
|
4033
|
+
}
|
|
4034
|
+
else {
|
|
4035
|
+
this.pushRow({ kind: 'error', text: `/subeffort failed: ${errorChain(error)}` });
|
|
4036
|
+
}
|
|
4037
|
+
this.markDirty();
|
|
4038
|
+
});
|
|
4039
|
+
break;
|
|
3598
4040
|
case 'mode':
|
|
3599
4041
|
void this.runModeCommand().catch((error) => {
|
|
3600
4042
|
if (error instanceof UserQuestionError) {
|
|
@@ -3609,6 +4051,9 @@ export class SshTui {
|
|
|
3609
4051
|
case 'clear':
|
|
3610
4052
|
this.rows.length = 0;
|
|
3611
4053
|
this.streaming = undefined;
|
|
4054
|
+
this.streamingReasoning = undefined;
|
|
4055
|
+
this.thinkingStartedAt = undefined;
|
|
4056
|
+
this.focusedRow = null;
|
|
3612
4057
|
break;
|
|
3613
4058
|
case 'status':
|
|
3614
4059
|
this.pushRow({
|
|
@@ -3660,7 +4105,13 @@ export class SshTui {
|
|
|
3660
4105
|
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
|
3661
4106
|
}],
|
|
3662
4107
|
agent: this.agent,
|
|
3663
|
-
}).then((answer) =>
|
|
4108
|
+
}).then((answer) => {
|
|
4109
|
+
this.pushRow({ kind: 'system', text: `dialog answer: ${JSON.stringify(answer)}` });
|
|
4110
|
+
this.markDirty();
|
|
4111
|
+
}, (error) => {
|
|
4112
|
+
this.pushRow({ kind: 'error', text: `dialog error: ${errorChain(error)}` });
|
|
4113
|
+
this.markDirty();
|
|
4114
|
+
});
|
|
3664
4115
|
break;
|
|
3665
4116
|
}
|
|
3666
4117
|
default:
|
|
@@ -3670,7 +4121,9 @@ export class SshTui {
|
|
|
3670
4121
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
3671
4122
|
break;
|
|
3672
4123
|
}
|
|
4124
|
+
this.commandAbort?.abort();
|
|
3673
4125
|
const controller = new AbortController();
|
|
4126
|
+
this.commandAbort = controller;
|
|
3674
4127
|
void commands.execute(this.agent, text, controller.signal).then((execution) => {
|
|
3675
4128
|
if (execution === undefined) {
|
|
3676
4129
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
@@ -3687,6 +4140,10 @@ export class SshTui {
|
|
|
3687
4140
|
}
|
|
3688
4141
|
}).catch((error) => {
|
|
3689
4142
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
4143
|
+
}).finally(() => {
|
|
4144
|
+
if (this.commandAbort === controller)
|
|
4145
|
+
this.commandAbort = undefined;
|
|
4146
|
+
this.markDirty();
|
|
3690
4147
|
});
|
|
3691
4148
|
}
|
|
3692
4149
|
break;
|
|
@@ -3696,21 +4153,76 @@ export class SshTui {
|
|
|
3696
4153
|
this.inputFolded = false;
|
|
3697
4154
|
this.markDirty();
|
|
3698
4155
|
}
|
|
4156
|
+
/** Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole. */
|
|
4157
|
+
graphemeBefore(cursor) {
|
|
4158
|
+
if (cursor <= 0)
|
|
4159
|
+
return { start: 0, end: 0 };
|
|
4160
|
+
let previousStart = 0;
|
|
4161
|
+
let previousEnd = 0;
|
|
4162
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4163
|
+
const start = segment.index;
|
|
4164
|
+
const end = start + segment.segment.length;
|
|
4165
|
+
if (cursor === start)
|
|
4166
|
+
return { start: previousStart, end: previousEnd };
|
|
4167
|
+
if (cursor > start && cursor < end)
|
|
4168
|
+
return { start, end };
|
|
4169
|
+
previousStart = start;
|
|
4170
|
+
previousEnd = end;
|
|
4171
|
+
}
|
|
4172
|
+
return { start: previousStart, end: previousEnd };
|
|
4173
|
+
}
|
|
4174
|
+
/** Grapheme cluster containing or following `cursor`. */
|
|
4175
|
+
graphemeAfter(cursor) {
|
|
4176
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4177
|
+
const start = segment.index;
|
|
4178
|
+
const end = start + segment.segment.length;
|
|
4179
|
+
if (cursor === start || (cursor > start && cursor < end))
|
|
4180
|
+
return { start, end };
|
|
4181
|
+
}
|
|
4182
|
+
return undefined;
|
|
4183
|
+
}
|
|
3699
4184
|
backspace() {
|
|
3700
4185
|
if (this.cursor === 0)
|
|
3701
4186
|
return;
|
|
3702
|
-
|
|
3703
|
-
this.
|
|
4187
|
+
const range = this.graphemeBefore(this.cursor);
|
|
4188
|
+
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
4189
|
+
this.cursor = range.start;
|
|
3704
4190
|
this.markDirty();
|
|
3705
4191
|
}
|
|
3706
4192
|
deleteAtCursor() {
|
|
3707
|
-
|
|
4193
|
+
const range = this.graphemeAfter(this.cursor);
|
|
4194
|
+
if (range === undefined)
|
|
3708
4195
|
return;
|
|
3709
|
-
this.input = `${this.input.slice(0,
|
|
4196
|
+
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
4197
|
+
this.cursor = range.start;
|
|
3710
4198
|
this.markDirty();
|
|
3711
4199
|
}
|
|
3712
4200
|
moveCursor(delta) {
|
|
3713
|
-
|
|
4201
|
+
if (delta < 0) {
|
|
4202
|
+
let target = 0;
|
|
4203
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4204
|
+
if (segment.index >= this.cursor)
|
|
4205
|
+
break;
|
|
4206
|
+
target = segment.index;
|
|
4207
|
+
}
|
|
4208
|
+
this.cursor = target;
|
|
4209
|
+
}
|
|
4210
|
+
else {
|
|
4211
|
+
let target = this.input.length;
|
|
4212
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4213
|
+
const start = segment.index;
|
|
4214
|
+
const end = start + segment.segment.length;
|
|
4215
|
+
if (start > this.cursor) {
|
|
4216
|
+
target = start;
|
|
4217
|
+
break;
|
|
4218
|
+
}
|
|
4219
|
+
if (end > this.cursor) {
|
|
4220
|
+
target = end;
|
|
4221
|
+
break;
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
this.cursor = target;
|
|
4225
|
+
}
|
|
3714
4226
|
this.markDirty();
|
|
3715
4227
|
}
|
|
3716
4228
|
historyBack() {
|