dsh-ssh-tui 0.1.4 → 0.1.6
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 +4 -0
- package/README.md +3 -1
- package/lib/index.js +53 -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/tui.js +445 -126
- package/lib/tui.js.map +1 -1
- package/lib/types/session-list.d.ts +2 -10
- package/lib/types/tui.d.ts +19 -0
- package/package.json +2 -1
package/lib/tui.js
CHANGED
|
@@ -62,6 +62,7 @@ const RENDER_INTERVAL_MS = 120;
|
|
|
62
62
|
const WAIT_INDICATOR_MS = 8000;
|
|
63
63
|
const STALL_WARNING_MS = 60000;
|
|
64
64
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
65
|
+
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
65
66
|
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
66
67
|
const MAX_TRANSCRIPT_ROWS = 5000;
|
|
67
68
|
const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -78,7 +79,12 @@ function displayDshPath(file) {
|
|
|
78
79
|
}
|
|
79
80
|
return `${home}\\${file}`.replaceAll('/', '\\');
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
const userHome = homedir();
|
|
83
|
+
if (home === userHome)
|
|
84
|
+
return `~/.dsh/${file}`;
|
|
85
|
+
if (home.startsWith(`${userHome}/`))
|
|
86
|
+
return `~/${home.slice(userHome.length + 1)}/${file}`;
|
|
87
|
+
return join(home, file);
|
|
82
88
|
}
|
|
83
89
|
const DSH_ENV_FILE = join(dshHomeDir(), IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
84
90
|
const DEEPSEEK_LOGO_VARIANTS = [
|
|
@@ -204,6 +210,12 @@ const LOCAL_COMMANDS = [
|
|
|
204
210
|
function displayWidth(text) {
|
|
205
211
|
let width = 0;
|
|
206
212
|
for (const char of text) {
|
|
213
|
+
if (char === '\t') {
|
|
214
|
+
// Tabs are expanded to spaces before rendering; keep the width
|
|
215
|
+
// calculation consistent with `sanitizeTerminalText()`.
|
|
216
|
+
width += 4;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
207
219
|
const cp = char.codePointAt(0) ?? 0;
|
|
208
220
|
const wide = (cp >= 0x1100 && cp <= 0x115f) ||
|
|
209
221
|
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
@@ -218,14 +230,25 @@ function displayWidth(text) {
|
|
|
218
230
|
}
|
|
219
231
|
return width;
|
|
220
232
|
}
|
|
233
|
+
/** Strip terminal control sequences and expand tabs for display output. */
|
|
234
|
+
function sanitizeTerminalText(text) {
|
|
235
|
+
return text
|
|
236
|
+
.replace(/[\x1b\u009b]/gu, '')
|
|
237
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
|
|
238
|
+
.replaceAll('\t', ' ');
|
|
239
|
+
}
|
|
240
|
+
/** UTF-16 length of the first code point, so fallback cuts never split a surrogate pair. */
|
|
241
|
+
function firstCodePointLength(text) {
|
|
242
|
+
return Array.from(text)[0]?.length ?? 1;
|
|
243
|
+
}
|
|
221
244
|
function wrap(text, width) {
|
|
222
245
|
const lines = [];
|
|
223
|
-
for (const
|
|
224
|
-
if (
|
|
246
|
+
for (const sourceLine of text.split('\n')) {
|
|
247
|
+
if (sourceLine === '') {
|
|
225
248
|
lines.push('');
|
|
226
249
|
continue;
|
|
227
250
|
}
|
|
228
|
-
let rest =
|
|
251
|
+
let rest = sanitizeTerminalText(sourceLine);
|
|
229
252
|
while (displayWidth(rest) > width) {
|
|
230
253
|
let cut = 0;
|
|
231
254
|
let used = 0;
|
|
@@ -237,7 +260,7 @@ function wrap(text, width) {
|
|
|
237
260
|
cut += char.length;
|
|
238
261
|
}
|
|
239
262
|
if (cut === 0)
|
|
240
|
-
cut =
|
|
263
|
+
cut = firstCodePointLength(rest);
|
|
241
264
|
lines.push(rest.slice(0, cut));
|
|
242
265
|
rest = rest.slice(cut);
|
|
243
266
|
}
|
|
@@ -247,9 +270,13 @@ function wrap(text, width) {
|
|
|
247
270
|
}
|
|
248
271
|
function truncate(text, maxLines) {
|
|
249
272
|
const lines = text.split('\n');
|
|
273
|
+
if (maxLines <= 0)
|
|
274
|
+
return '';
|
|
250
275
|
if (lines.length <= maxLines)
|
|
251
276
|
return text;
|
|
252
|
-
|
|
277
|
+
if (maxLines === 1)
|
|
278
|
+
return `… ${lines.length - 1} more line(s) …`;
|
|
279
|
+
const head = lines.slice(0, Math.max(0, maxLines - 2));
|
|
253
280
|
const tail = lines.slice(-1);
|
|
254
281
|
return [...head, `… ${lines.length - head.length - 1} more line(s) …`, ...tail].join('\n');
|
|
255
282
|
}
|
|
@@ -354,11 +381,12 @@ function markdownBaseCode(kind) {
|
|
|
354
381
|
}
|
|
355
382
|
/** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
|
|
356
383
|
function renderMarkdownBlockLine(block, color) {
|
|
384
|
+
const segments = block.segments.map(segment => ({ ...segment, text: sanitizeTerminalText(segment.text) }));
|
|
357
385
|
if (!color)
|
|
358
|
-
return
|
|
386
|
+
return segments.map(segment => segment.text).join('');
|
|
359
387
|
const base = markdownBaseCode(block.base);
|
|
360
388
|
let out = `\x1b[${base}m`;
|
|
361
|
-
for (const segment of
|
|
389
|
+
for (const segment of segments) {
|
|
362
390
|
const code = markdownSegmentCode(segment.kind);
|
|
363
391
|
if (code === '') {
|
|
364
392
|
out += segment.text;
|
|
@@ -402,7 +430,8 @@ function headingSegments(text, level) {
|
|
|
402
430
|
export function renderMarkdownLines(text, width, color) {
|
|
403
431
|
const lines = [];
|
|
404
432
|
let inFence = false;
|
|
405
|
-
for (const
|
|
433
|
+
for (const sourceLine of text.split('\n')) {
|
|
434
|
+
const raw = sanitizeTerminalText(sourceLine);
|
|
406
435
|
const fence = /^```([^\n]*)$/u.exec(raw.trim());
|
|
407
436
|
if (fence !== null) {
|
|
408
437
|
inFence = !inFence;
|
|
@@ -484,21 +513,27 @@ export function renderMarkdownLines(text, width, color) {
|
|
|
484
513
|
return lines;
|
|
485
514
|
}
|
|
486
515
|
/** Cut one line to fit a width, appending an ellipsis when truncated. */
|
|
487
|
-
function truncateToWidth(text, width) {
|
|
488
|
-
|
|
489
|
-
|
|
516
|
+
export function truncateToWidth(text, width) {
|
|
517
|
+
const safe = sanitizeTerminalText(text);
|
|
518
|
+
if (width <= 0)
|
|
519
|
+
return '';
|
|
520
|
+
if (displayWidth(safe) <= width)
|
|
521
|
+
return safe;
|
|
522
|
+
if (width === 1)
|
|
523
|
+
return '…';
|
|
524
|
+
const limit = width - 1;
|
|
490
525
|
let cut = 0;
|
|
491
526
|
let used = 0;
|
|
492
|
-
for (const char of
|
|
527
|
+
for (const char of safe) {
|
|
493
528
|
const charWidth = displayWidth(char);
|
|
494
|
-
if (used + charWidth >
|
|
529
|
+
if (used + charWidth > limit)
|
|
495
530
|
break;
|
|
496
531
|
used += charWidth;
|
|
497
532
|
cut += char.length;
|
|
498
533
|
}
|
|
499
534
|
if (cut === 0)
|
|
500
|
-
cut =
|
|
501
|
-
return `${
|
|
535
|
+
cut = firstCodePointLength(safe);
|
|
536
|
+
return `${safe.slice(0, cut)}…`;
|
|
502
537
|
}
|
|
503
538
|
/** Slice up to `maxWidth` display columns from the beginning of `text`. */
|
|
504
539
|
function forwardSliceByWidth(text, maxWidth) {
|
|
@@ -616,9 +651,11 @@ export function openCodeSourceFor(provider, llmPiAiSection) {
|
|
|
616
651
|
return null;
|
|
617
652
|
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
618
653
|
? profile.apiKeyEnv
|
|
619
|
-
: provider === 'opencode'
|
|
654
|
+
: provider === 'opencode'
|
|
620
655
|
? 'OPENCODE_API_KEY'
|
|
621
|
-
:
|
|
656
|
+
: provider === 'opencode-go'
|
|
657
|
+
? 'OPENCODE_GO_API_KEY'
|
|
658
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
622
659
|
const label = typeof profile?.displayName === 'string' && profile.displayName.trim() !== ''
|
|
623
660
|
? profile.displayName
|
|
624
661
|
: isGo ? 'OpenCode Go' : 'OpenCode Zen';
|
|
@@ -720,6 +757,8 @@ function isEscapePrefix(text) {
|
|
|
720
757
|
return false;
|
|
721
758
|
if (text === '\x1b[')
|
|
722
759
|
return true;
|
|
760
|
+
if (text === '\x1bO' || /^\x1bO[A-Z]?$/u.test(text))
|
|
761
|
+
return true;
|
|
723
762
|
if (/^\x1b\[[A-D]$/u.test(text))
|
|
724
763
|
return true;
|
|
725
764
|
if (/^\x1b\[[HF]$/u.test(text))
|
|
@@ -749,11 +788,23 @@ function scalarText(value) {
|
|
|
749
788
|
}
|
|
750
789
|
return null;
|
|
751
790
|
}
|
|
791
|
+
/** Take the first `max` code points of a string without splitting surrogates. */
|
|
792
|
+
function sliceCodePoints(text, max) {
|
|
793
|
+
if (max <= 0)
|
|
794
|
+
return '';
|
|
795
|
+
return Array.from(text).slice(0, max).join('');
|
|
796
|
+
}
|
|
797
|
+
/** Take the last `max` code points of a string without splitting surrogates. */
|
|
798
|
+
function lastCodePoints(text, max) {
|
|
799
|
+
if (max <= 0)
|
|
800
|
+
return '';
|
|
801
|
+
return Array.from(text).slice(-max).join('');
|
|
802
|
+
}
|
|
752
803
|
/** Prefer the fields a human scans for; fall back to the first scalar pairs. */
|
|
753
804
|
function friendlyArgsSummary(name, args) {
|
|
754
805
|
const parsed = parseJsonArgs(args);
|
|
755
806
|
if (parsed === null)
|
|
756
|
-
return args
|
|
807
|
+
return sliceCodePoints(args, 120);
|
|
757
808
|
const preferred = [
|
|
758
809
|
'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
|
|
759
810
|
'description', 'content', 'file_text', 'old_string', 'new_string',
|
|
@@ -779,7 +830,7 @@ function friendlyArgsSummary(name, args) {
|
|
|
779
830
|
}
|
|
780
831
|
}
|
|
781
832
|
const summary = parts.join(' ');
|
|
782
|
-
return summary === '' ? name : summary
|
|
833
|
+
return summary === '' ? name : sliceCodePoints(summary, 160);
|
|
783
834
|
}
|
|
784
835
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
785
836
|
const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
@@ -827,7 +878,7 @@ function diffHunksFromArgs(name, argsRaw) {
|
|
|
827
878
|
export function presentToolCall(name, args) {
|
|
828
879
|
const parsed = parseJsonArgs(args);
|
|
829
880
|
if (SHELL_TOOL_NAMES.has(name)) {
|
|
830
|
-
const command = typeof parsed?.command === 'string' ? parsed.command : args
|
|
881
|
+
const command = typeof parsed?.command === 'string' ? parsed.command : sliceCodePoints(args, 80);
|
|
831
882
|
return {
|
|
832
883
|
title: name,
|
|
833
884
|
summary: `$ ${command}`,
|
|
@@ -873,6 +924,17 @@ function diffContentLines(text) {
|
|
|
873
924
|
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
874
925
|
return body.split('\n');
|
|
875
926
|
}
|
|
927
|
+
/** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
|
|
928
|
+
function capDisplayLines(lines, maxLines) {
|
|
929
|
+
const budget = Math.max(1, Math.floor(maxLines));
|
|
930
|
+
if (lines.length <= budget)
|
|
931
|
+
return [...lines];
|
|
932
|
+
const omitted = lines.length - budget + 1;
|
|
933
|
+
const marker = { kind: 'tool-result', text: `… ${omitted} more line(s) …` };
|
|
934
|
+
if (budget === 1)
|
|
935
|
+
return [marker];
|
|
936
|
+
return [...lines.slice(0, budget - 2), marker, ...lines.slice(-1)];
|
|
937
|
+
}
|
|
876
938
|
/** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
|
|
877
939
|
export function renderToolDiff(diffs, maxLines) {
|
|
878
940
|
const rows = [];
|
|
@@ -901,15 +963,7 @@ export function renderToolDiff(diffs, maxLines) {
|
|
|
901
963
|
kind: 'tool-result',
|
|
902
964
|
text: `└ +${added} -${removed} · ${paths.size} file${paths.size === 1 ? '' : 's'}`,
|
|
903
965
|
});
|
|
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
|
-
];
|
|
966
|
+
return capDisplayLines(rows, maxLines);
|
|
913
967
|
}
|
|
914
968
|
/** Keys whose multiline strings render as indented content blocks. */
|
|
915
969
|
const LONG_TEXT_KEYS = new Set([
|
|
@@ -917,6 +971,8 @@ const LONG_TEXT_KEYS = new Set([
|
|
|
917
971
|
'plan', 'markdown', 'details', 'description', 'text',
|
|
918
972
|
]);
|
|
919
973
|
const JSON_STRING_CAP = 400;
|
|
974
|
+
const JSON_MAX_DEPTH = 16;
|
|
975
|
+
const JSON_MAX_ENTRIES = 60;
|
|
920
976
|
/** Convert any parsed JSON value into readable indented display lines. */
|
|
921
977
|
export function friendlyJsonLines(value, depth = 0) {
|
|
922
978
|
const pad = ' '.repeat(depth);
|
|
@@ -929,11 +985,15 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
929
985
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
930
986
|
return [`${pad}${String(value)}`];
|
|
931
987
|
}
|
|
988
|
+
if (depth >= JSON_MAX_DEPTH) {
|
|
989
|
+
return [`${pad}…`];
|
|
990
|
+
}
|
|
932
991
|
if (Array.isArray(value)) {
|
|
933
992
|
if (value.length === 0)
|
|
934
993
|
return [`${pad}[]`];
|
|
994
|
+
const shown = value.slice(0, JSON_MAX_ENTRIES);
|
|
935
995
|
const lines = [];
|
|
936
|
-
for (const item of
|
|
996
|
+
for (const item of shown) {
|
|
937
997
|
if (item !== null && typeof item === 'object') {
|
|
938
998
|
lines.push(`${pad}-`);
|
|
939
999
|
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
@@ -942,14 +1002,17 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
942
1002
|
lines.push(`${pad}- ${friendlyJsonLines(item, 0)[0] ?? ''}`);
|
|
943
1003
|
}
|
|
944
1004
|
}
|
|
1005
|
+
if (value.length > shown.length)
|
|
1006
|
+
lines.push(`${pad}… ${value.length - shown.length} more item(s)`);
|
|
945
1007
|
return lines;
|
|
946
1008
|
}
|
|
947
1009
|
if (typeof value === 'object') {
|
|
948
1010
|
const entries = Object.entries(value);
|
|
949
1011
|
if (entries.length === 0)
|
|
950
1012
|
return [`${pad}{}`];
|
|
1013
|
+
const shown = entries.slice(0, JSON_MAX_ENTRIES);
|
|
951
1014
|
const lines = [];
|
|
952
|
-
for (const [key, item] of
|
|
1015
|
+
for (const [key, item] of shown) {
|
|
953
1016
|
if (typeof item === 'string' && item.includes('\n') && LONG_TEXT_KEYS.has(key)) {
|
|
954
1017
|
const contentLines = item.split('\n');
|
|
955
1018
|
lines.push(`${pad}${key}:`);
|
|
@@ -969,6 +1032,8 @@ export function friendlyJsonLines(value, depth = 0) {
|
|
|
969
1032
|
lines.push(`${pad}${key}: ${scalar}`);
|
|
970
1033
|
}
|
|
971
1034
|
}
|
|
1035
|
+
if (entries.length > shown.length)
|
|
1036
|
+
lines.push(`${pad}… ${entries.length - shown.length} more field(s)`);
|
|
972
1037
|
return lines;
|
|
973
1038
|
}
|
|
974
1039
|
return [`${pad}${String(value)}`];
|
|
@@ -1031,7 +1096,7 @@ export function toolBodyLines(row, maxLines) {
|
|
|
1031
1096
|
}
|
|
1032
1097
|
}
|
|
1033
1098
|
}
|
|
1034
|
-
return out;
|
|
1099
|
+
return capDisplayLines(out, maxLines);
|
|
1035
1100
|
}
|
|
1036
1101
|
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
1037
1102
|
export function parseExitStatus(text) {
|
|
@@ -1065,6 +1130,7 @@ export function formatDuration(ms) {
|
|
|
1065
1130
|
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1066
1131
|
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1067
1132
|
}
|
|
1133
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
1068
1134
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1069
1135
|
export class SshTui {
|
|
1070
1136
|
ctx;
|
|
@@ -1079,6 +1145,8 @@ export class SshTui {
|
|
|
1079
1145
|
historyIndex = -1;
|
|
1080
1146
|
status = 'idle';
|
|
1081
1147
|
dialog;
|
|
1148
|
+
dialogQueue = [];
|
|
1149
|
+
onboardingCompletion;
|
|
1082
1150
|
dirty = true;
|
|
1083
1151
|
disposed = false;
|
|
1084
1152
|
exiting = false;
|
|
@@ -1108,6 +1176,7 @@ export class SshTui {
|
|
|
1108
1176
|
lastActivity = Date.now();
|
|
1109
1177
|
stalledWarningShown = false;
|
|
1110
1178
|
lastPaintAt = 0;
|
|
1179
|
+
commandAbort;
|
|
1111
1180
|
activeSubagents = new Map();
|
|
1112
1181
|
subagentSessions = new Set();
|
|
1113
1182
|
openToolCalls = new Map();
|
|
@@ -1133,6 +1202,7 @@ export class SshTui {
|
|
|
1133
1202
|
escapeTimer;
|
|
1134
1203
|
thinkingStartedAt;
|
|
1135
1204
|
completionSignaled = false;
|
|
1205
|
+
replaying = false;
|
|
1136
1206
|
completedAt = 0;
|
|
1137
1207
|
lastTitleUpdateAt = 0;
|
|
1138
1208
|
lastPaintRows = [];
|
|
@@ -1144,7 +1214,8 @@ export class SshTui {
|
|
|
1144
1214
|
this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
|
|
1145
1215
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
1146
1216
|
this.showReasoning = config.showReasoning !== false;
|
|
1147
|
-
this.goodbye =
|
|
1217
|
+
this.goodbye = config.goodbye
|
|
1218
|
+
?? this.ctx.get('tuiGoodbyeMessage')
|
|
1148
1219
|
?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
|
|
1149
1220
|
this.resume = config.resume === true;
|
|
1150
1221
|
this.providerName = config.provider ?? 'deepseek-official';
|
|
@@ -1202,14 +1273,27 @@ export class SshTui {
|
|
|
1202
1273
|
}
|
|
1203
1274
|
}, RENDER_INTERVAL_MS);
|
|
1204
1275
|
this.renderTimer.unref?.();
|
|
1205
|
-
void this.maybeRunOnboarding()
|
|
1276
|
+
void this.maybeRunOnboarding().catch((error) => {
|
|
1277
|
+
if (this.disposed)
|
|
1278
|
+
return;
|
|
1279
|
+
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
1280
|
+
this.markDirty();
|
|
1281
|
+
});
|
|
1206
1282
|
}
|
|
1207
1283
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1208
1284
|
replayHistory() {
|
|
1209
|
-
|
|
1210
|
-
|
|
1285
|
+
this.replaying = true;
|
|
1286
|
+
try {
|
|
1287
|
+
for (const event of this.agent.session.events) {
|
|
1288
|
+
this.handleSessionEvent(this.agent.session, event);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
finally {
|
|
1292
|
+
this.replaying = false;
|
|
1211
1293
|
}
|
|
1212
1294
|
this.streaming = undefined;
|
|
1295
|
+
this.streamingReasoning = undefined;
|
|
1296
|
+
this.thinkingStartedAt = undefined;
|
|
1213
1297
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
1214
1298
|
this.dirty = true;
|
|
1215
1299
|
}
|
|
@@ -1222,6 +1306,8 @@ export class SshTui {
|
|
|
1222
1306
|
let stored = false;
|
|
1223
1307
|
if (credentials !== undefined) {
|
|
1224
1308
|
stored = (await credentials.describe(credentialRef(envRef))).configured;
|
|
1309
|
+
if (this.disposed)
|
|
1310
|
+
return;
|
|
1225
1311
|
}
|
|
1226
1312
|
if (!stored) {
|
|
1227
1313
|
// Belt-and-braces: the file provider may not have its in-memory snapshot
|
|
@@ -1230,6 +1316,8 @@ export class SshTui {
|
|
|
1230
1316
|
const credentialFile = join(dshHomeDir(), '.credentials.yaml');
|
|
1231
1317
|
if (existsSync(credentialFile)) {
|
|
1232
1318
|
const content = await readFile(credentialFile, 'utf8');
|
|
1319
|
+
if (this.disposed)
|
|
1320
|
+
return;
|
|
1233
1321
|
stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
|
|
1234
1322
|
}
|
|
1235
1323
|
}
|
|
@@ -1260,7 +1348,9 @@ export class SshTui {
|
|
|
1260
1348
|
}
|
|
1261
1349
|
/** Run the provider/API-key onboarding wizard. Resolves true when saved. */
|
|
1262
1350
|
runOnboarding() {
|
|
1263
|
-
|
|
1351
|
+
if (this.onboardingCompletion !== undefined)
|
|
1352
|
+
return this.onboardingCompletion;
|
|
1353
|
+
this.onboardingCompletion = new Promise((resolve) => {
|
|
1264
1354
|
this.onboarding = {
|
|
1265
1355
|
step: 'provider',
|
|
1266
1356
|
providerType: 'official',
|
|
@@ -1268,23 +1358,30 @@ export class SshTui {
|
|
|
1268
1358
|
baseUrl: '',
|
|
1269
1359
|
key: '',
|
|
1270
1360
|
models: [],
|
|
1271
|
-
|
|
1361
|
+
saving: false,
|
|
1362
|
+
resolve: (saved) => {
|
|
1363
|
+
this.onboardingCompletion = undefined;
|
|
1364
|
+
resolve(saved);
|
|
1365
|
+
},
|
|
1272
1366
|
};
|
|
1273
1367
|
this.input = '';
|
|
1274
1368
|
this.cursor = 0;
|
|
1275
1369
|
this.dialog = { kind: 'onboarding' };
|
|
1276
1370
|
this.markDirty();
|
|
1277
1371
|
});
|
|
1372
|
+
return this.onboardingCompletion;
|
|
1278
1373
|
}
|
|
1279
1374
|
cancelOnboarding() {
|
|
1280
1375
|
const state = this.onboarding;
|
|
1281
|
-
if (state === undefined)
|
|
1376
|
+
if (state === undefined || state.saving)
|
|
1282
1377
|
return;
|
|
1283
1378
|
this.onboarding = undefined;
|
|
1284
|
-
this.dialog
|
|
1379
|
+
if (this.dialog?.kind === 'onboarding')
|
|
1380
|
+
this.dialog = undefined;
|
|
1285
1381
|
this.input = '';
|
|
1286
1382
|
this.cursor = 0;
|
|
1287
1383
|
state.resolve(false);
|
|
1384
|
+
this.showNextDialog();
|
|
1288
1385
|
this.markDirty();
|
|
1289
1386
|
}
|
|
1290
1387
|
/** Restore the terminal, flush the session, and request process exit. */
|
|
@@ -1294,6 +1391,8 @@ export class SshTui {
|
|
|
1294
1391
|
this.disposed = true;
|
|
1295
1392
|
this.exiting = true;
|
|
1296
1393
|
const dialog = this.dialog;
|
|
1394
|
+
const queued = this.dialogQueue.splice(0);
|
|
1395
|
+
this.dialog = undefined;
|
|
1297
1396
|
if (dialog !== undefined) {
|
|
1298
1397
|
if (dialog.kind === 'confirm') {
|
|
1299
1398
|
dialog.resolve('cancel');
|
|
@@ -1304,10 +1403,23 @@ export class SshTui {
|
|
|
1304
1403
|
else {
|
|
1305
1404
|
this.cancelOnboarding();
|
|
1306
1405
|
}
|
|
1307
|
-
|
|
1406
|
+
}
|
|
1407
|
+
for (const pending of queued) {
|
|
1408
|
+
if (pending.kind === 'confirm') {
|
|
1409
|
+
pending.resolve('cancel');
|
|
1410
|
+
}
|
|
1411
|
+
else if (pending.kind === 'questions') {
|
|
1412
|
+
pending.reject(new UserQuestionError('TUI closed before the question was answered', 'ASK_ABORTED'));
|
|
1413
|
+
}
|
|
1308
1414
|
}
|
|
1309
1415
|
if (this.renderTimer !== undefined)
|
|
1310
1416
|
clearInterval(this.renderTimer);
|
|
1417
|
+
this.renderTimer = undefined;
|
|
1418
|
+
if (this.escapeTimer !== undefined)
|
|
1419
|
+
clearTimeout(this.escapeTimer);
|
|
1420
|
+
this.escapeTimer = undefined;
|
|
1421
|
+
this.commandAbort?.abort();
|
|
1422
|
+
this.commandAbort = undefined;
|
|
1311
1423
|
for (const dispose of this.disposers.splice(0)) {
|
|
1312
1424
|
dispose();
|
|
1313
1425
|
}
|
|
@@ -1331,7 +1443,7 @@ export class SshTui {
|
|
|
1331
1443
|
return;
|
|
1332
1444
|
this.exiting = true;
|
|
1333
1445
|
await this.dispose();
|
|
1334
|
-
process.stdout.write(`\n${this.goodbye}\n`);
|
|
1446
|
+
process.stdout.write(`\n${sanitizeTerminalText(this.goodbye)}\n`);
|
|
1335
1447
|
try {
|
|
1336
1448
|
await this.ctx.get('sessions')?.flush(this.agent.session);
|
|
1337
1449
|
}
|
|
@@ -1470,9 +1582,16 @@ export class SshTui {
|
|
|
1470
1582
|
if (row.kind === 'tool') {
|
|
1471
1583
|
const running = row.status === undefined || row.status === 'running';
|
|
1472
1584
|
const ok = row.status === 'ok';
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1585
|
+
// The status dot carries its own ANSI color. `styleLine` sanitizes its
|
|
1586
|
+
// input, so embedding the escape sequence there would leave literal
|
|
1587
|
+
// "[33m" text on screen; color the dot between two sanitized halves.
|
|
1588
|
+
const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
|
|
1589
|
+
const styleToolHeader = (line) => {
|
|
1590
|
+
const dotIndex = line.indexOf('●');
|
|
1591
|
+
if (dotColor === undefined || dotIndex === -1)
|
|
1592
|
+
return this.styleLine('tool', line);
|
|
1593
|
+
return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
|
|
1594
|
+
};
|
|
1476
1595
|
const state = running ? 'running…' : ok ? 'ok' : 'error';
|
|
1477
1596
|
const summary = row.summary === '' ? '' : ` ${row.summary}`;
|
|
1478
1597
|
const exit = !running && row.command !== undefined
|
|
@@ -1487,20 +1606,12 @@ export class SshTui {
|
|
|
1487
1606
|
const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
|
|
1488
1607
|
if (!row.expanded) {
|
|
1489
1608
|
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);
|
|
1609
|
+
const styled = styleToolHeader(collapsed);
|
|
1495
1610
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
1496
1611
|
continue;
|
|
1497
1612
|
}
|
|
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);
|
|
1613
|
+
for (const wrapped of wrap(plainHeader, width)) {
|
|
1614
|
+
addDisplay(styleToolHeader(wrapped), row);
|
|
1504
1615
|
}
|
|
1505
1616
|
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
1506
1617
|
for (const wrapped of wrap(line.text, Math.max(1, width - 2))) {
|
|
@@ -1600,7 +1711,7 @@ export class SshTui {
|
|
|
1600
1711
|
addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
|
|
1601
1712
|
addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
|
|
1602
1713
|
addDialog(` 模型: ${ob.models.join(', ')}`);
|
|
1603
|
-
addDialog(` API Key: ${ob.key
|
|
1714
|
+
addDialog(` API Key: ${sliceCodePoints(ob.key, 6)}…${lastCodePoints(ob.key, 4)}(长度 ${ob.key.length})`);
|
|
1604
1715
|
addDialog(' y = 保存, n = 重填, Esc = 取消');
|
|
1605
1716
|
break;
|
|
1606
1717
|
}
|
|
@@ -1615,13 +1726,14 @@ export class SshTui {
|
|
|
1615
1726
|
const options = d.question.options ?? [];
|
|
1616
1727
|
for (const [index, option] of options.entries()) {
|
|
1617
1728
|
const marker = d.selected.has(index) ? '●' : '○';
|
|
1729
|
+
const key = QUESTION_OPTION_KEYS[index] ?? '?';
|
|
1618
1730
|
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
1619
|
-
addDialog(` ${
|
|
1731
|
+
addDialog(` ${key} ${marker} ${option.label}${extra}`);
|
|
1620
1732
|
}
|
|
1621
1733
|
if (options.length === 0) {
|
|
1622
1734
|
addDialog(' (free text: type below and press Enter)');
|
|
1623
1735
|
}
|
|
1624
|
-
addDialog(` ${d.question.multiSelect === true ? 'digits toggle, Enter submit' : 'digit to select, Enter submit'}, Esc to cancel`);
|
|
1736
|
+
addDialog(` ${d.question.multiSelect === true ? 'digits/letters toggle, Enter submit' : 'digit/letter to select, Enter submit'}, Esc to cancel`);
|
|
1625
1737
|
}
|
|
1626
1738
|
}
|
|
1627
1739
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
@@ -1641,7 +1753,7 @@ export class SshTui {
|
|
|
1641
1753
|
for (const [index, command] of this.commandSuggestions.entries()) {
|
|
1642
1754
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
1643
1755
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
1644
|
-
suggestionLines.push(index === this.suggestionIndex
|
|
1756
|
+
suggestionLines.push(index === this.suggestionIndex && this.color
|
|
1645
1757
|
? `\x1b[7m${fitLine(line)}\x1b[27m`
|
|
1646
1758
|
: this.styleLine('system', fitLine(line)));
|
|
1647
1759
|
}
|
|
@@ -1913,8 +2025,9 @@ export class SshTui {
|
|
|
1913
2025
|
this.paint();
|
|
1914
2026
|
};
|
|
1915
2027
|
styleLine(kind, text) {
|
|
2028
|
+
const safe = sanitizeTerminalText(text);
|
|
1916
2029
|
if (!this.color)
|
|
1917
|
-
return
|
|
2030
|
+
return safe;
|
|
1918
2031
|
const code = kind === 'user' ? '36' :
|
|
1919
2032
|
kind === 'assistant' ? '1;37' :
|
|
1920
2033
|
kind === 'reasoning' ? '2;3' :
|
|
@@ -1925,7 +2038,7 @@ export class SshTui {
|
|
|
1925
2038
|
kind === 'diff-path' ? '1;36' :
|
|
1926
2039
|
kind === 'error' ? '31' :
|
|
1927
2040
|
'90';
|
|
1928
|
-
return `\x1b[${code}m${
|
|
2041
|
+
return `\x1b[${code}m${safe}`;
|
|
1929
2042
|
}
|
|
1930
2043
|
// ── event handling ──────────────────────────────────────────────────────
|
|
1931
2044
|
handleSessionEvent = (session, event) => {
|
|
@@ -2116,6 +2229,9 @@ export class SshTui {
|
|
|
2116
2229
|
}
|
|
2117
2230
|
this.stats.steps += 1;
|
|
2118
2231
|
this.openStepStats = undefined;
|
|
2232
|
+
// Usage accounting is complete for this step; the map only exists to
|
|
2233
|
+
// deduplicate repeated usage reports during the step.
|
|
2234
|
+
this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
|
|
2119
2235
|
this.markDirty();
|
|
2120
2236
|
break;
|
|
2121
2237
|
}
|
|
@@ -2130,7 +2246,12 @@ export class SshTui {
|
|
|
2130
2246
|
this.pendingToolTimes.clear();
|
|
2131
2247
|
this.stalledWarningShown = false;
|
|
2132
2248
|
this.pendingMessages.clear();
|
|
2133
|
-
|
|
2249
|
+
// Aborted/errored turns may close without an assembled
|
|
2250
|
+
// assistant/message; never leave a half-streamed thinking block behind.
|
|
2251
|
+
this.streaming = undefined;
|
|
2252
|
+
this.streamingReasoning = undefined;
|
|
2253
|
+
this.thinkingStartedAt = undefined;
|
|
2254
|
+
if (reason.kind === 'completed' && !this.replaying && !this.completionSignaled) {
|
|
2134
2255
|
this.completionSignaled = true;
|
|
2135
2256
|
this.completedAt = Date.now();
|
|
2136
2257
|
this.updateTerminalTitle();
|
|
@@ -2215,7 +2336,7 @@ export class SshTui {
|
|
|
2215
2336
|
break;
|
|
2216
2337
|
}
|
|
2217
2338
|
case 'tool/call':
|
|
2218
|
-
this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${event.data.arguments
|
|
2339
|
+
this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${sliceCodePoints(event.data.arguments, 160)}` });
|
|
2219
2340
|
break;
|
|
2220
2341
|
case 'tool/result': {
|
|
2221
2342
|
const output = truncate(collectText(event.data.message.content), 4);
|
|
@@ -2260,16 +2381,23 @@ export class SshTui {
|
|
|
2260
2381
|
this.markDirty();
|
|
2261
2382
|
};
|
|
2262
2383
|
// ── approval and questions ──────────────────────────────────────────────
|
|
2263
|
-
handleApproval = async (request,
|
|
2384
|
+
handleApproval = async (request, _next) => {
|
|
2264
2385
|
const agentLabel = request.agent.id === this.agent.id
|
|
2265
2386
|
? '当前会话'
|
|
2266
2387
|
: `子代理 ${request.agent.id}`;
|
|
2267
2388
|
return new Promise((resolve) => {
|
|
2389
|
+
if (request.signal?.aborted === true) {
|
|
2390
|
+
resolve('cancelled');
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
let dialog;
|
|
2268
2394
|
const onAbort = () => {
|
|
2269
|
-
|
|
2395
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2396
|
+
if (dialog !== undefined)
|
|
2397
|
+
this.abortConfirm(dialog);
|
|
2270
2398
|
};
|
|
2271
2399
|
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2272
|
-
this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
|
|
2400
|
+
dialog = this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
|
|
2273
2401
|
request.signal?.removeEventListener('abort', onAbort);
|
|
2274
2402
|
resolve(answer === 'y' ? 'allowed-once' : answer === 'n' ? 'rejected' : 'cancelled');
|
|
2275
2403
|
});
|
|
@@ -2282,54 +2410,114 @@ export class SshTui {
|
|
|
2282
2410
|
: `子代理 ${request.agent.id}`;
|
|
2283
2411
|
for (const [index, question] of request.questions.entries()) {
|
|
2284
2412
|
const answer = await new Promise((resolve, reject) => {
|
|
2413
|
+
const fail = (error) => {
|
|
2414
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2415
|
+
reject(error);
|
|
2416
|
+
};
|
|
2285
2417
|
const onAbort = () => {
|
|
2286
|
-
|
|
2287
|
-
|
|
2418
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2419
|
+
if (dialog !== undefined) {
|
|
2420
|
+
dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
2421
|
+
}
|
|
2422
|
+
else {
|
|
2423
|
+
reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
2424
|
+
}
|
|
2288
2425
|
};
|
|
2426
|
+
let dialog;
|
|
2289
2427
|
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2428
|
+
if (request.signal?.aborted === true) {
|
|
2429
|
+
onAbort();
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2290
2432
|
const labeled = agentLabel === undefined
|
|
2291
2433
|
? question
|
|
2292
2434
|
: { ...question, question: `[${agentLabel}] ${question.question}` };
|
|
2293
|
-
this.openQuestion(labeled, index, request.questions.length, (selection) => {
|
|
2435
|
+
dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
|
|
2294
2436
|
request.signal?.removeEventListener('abort', onAbort);
|
|
2295
2437
|
resolve(selection);
|
|
2296
|
-
},
|
|
2438
|
+
}, fail);
|
|
2297
2439
|
});
|
|
2298
2440
|
answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
|
|
2299
2441
|
}
|
|
2300
2442
|
return { answers };
|
|
2301
2443
|
};
|
|
2302
|
-
|
|
2303
|
-
|
|
2444
|
+
/** Queue one dialog behind an already-open one instead of overwriting it. */
|
|
2445
|
+
openDialog(dialog) {
|
|
2446
|
+
if (this.dialog === undefined) {
|
|
2447
|
+
this.dialog = dialog;
|
|
2448
|
+
}
|
|
2449
|
+
else {
|
|
2450
|
+
this.dialogQueue.push(dialog);
|
|
2451
|
+
}
|
|
2452
|
+
this.markDirty();
|
|
2453
|
+
}
|
|
2454
|
+
showNextDialog() {
|
|
2455
|
+
if (this.dialog !== undefined)
|
|
2456
|
+
return;
|
|
2457
|
+
const next = this.dialogQueue.shift();
|
|
2458
|
+
if (next !== undefined) {
|
|
2459
|
+
this.dialog = next;
|
|
2460
|
+
this.markDirty();
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
removeQueuedDialog(dialog) {
|
|
2464
|
+
const index = this.dialogQueue.indexOf(dialog);
|
|
2465
|
+
if (index !== -1)
|
|
2466
|
+
this.dialogQueue.splice(index, 1);
|
|
2467
|
+
}
|
|
2468
|
+
settleQuestion(dialog, finish) {
|
|
2469
|
+
if (this.dialog === dialog) {
|
|
2470
|
+
this.dialog = undefined;
|
|
2471
|
+
}
|
|
2472
|
+
else {
|
|
2473
|
+
this.removeQueuedDialog(dialog);
|
|
2474
|
+
}
|
|
2475
|
+
finish();
|
|
2476
|
+
this.showNextDialog();
|
|
2304
2477
|
this.markDirty();
|
|
2305
2478
|
}
|
|
2479
|
+
openConfirm(prompt, hint, resolve) {
|
|
2480
|
+
const dialog = { kind: 'confirm', prompt, hint, resolve };
|
|
2481
|
+
this.openDialog(dialog);
|
|
2482
|
+
return dialog;
|
|
2483
|
+
}
|
|
2306
2484
|
closeConfirm(value) {
|
|
2307
2485
|
const dialog = this.dialog;
|
|
2308
2486
|
if (dialog === undefined || dialog.kind !== 'confirm')
|
|
2309
2487
|
return;
|
|
2310
2488
|
this.dialog = undefined;
|
|
2311
2489
|
dialog.resolve(value);
|
|
2490
|
+
this.showNextDialog();
|
|
2312
2491
|
this.markDirty();
|
|
2313
2492
|
}
|
|
2493
|
+
/** Resolve one queued or active confirm from its abort signal. */
|
|
2494
|
+
abortConfirm(dialog) {
|
|
2495
|
+
if (this.dialog === dialog) {
|
|
2496
|
+
this.dialog = undefined;
|
|
2497
|
+
dialog.resolve('cancel');
|
|
2498
|
+
this.showNextDialog();
|
|
2499
|
+
this.markDirty();
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
this.removeQueuedDialog(dialog);
|
|
2503
|
+
dialog.resolve('cancel');
|
|
2504
|
+
}
|
|
2314
2505
|
openQuestion(question, index, total, resolve, reject) {
|
|
2315
|
-
|
|
2506
|
+
const dialog = {
|
|
2316
2507
|
kind: 'questions',
|
|
2317
2508
|
question,
|
|
2318
2509
|
index,
|
|
2319
2510
|
total,
|
|
2320
2511
|
selected: new Set(),
|
|
2321
2512
|
resolve: (selection) => {
|
|
2322
|
-
this.dialog
|
|
2323
|
-
resolve(selection);
|
|
2324
|
-
this.markDirty();
|
|
2513
|
+
this.settleQuestion(dialog, () => resolve(selection));
|
|
2325
2514
|
},
|
|
2326
2515
|
reject: (error) => {
|
|
2327
|
-
this.dialog
|
|
2328
|
-
reject(error);
|
|
2329
|
-
this.markDirty();
|
|
2516
|
+
this.settleQuestion(dialog, () => reject(error));
|
|
2330
2517
|
},
|
|
2331
2518
|
};
|
|
2332
|
-
this.
|
|
2519
|
+
this.openDialog(dialog);
|
|
2520
|
+
return dialog;
|
|
2333
2521
|
}
|
|
2334
2522
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
2335
2523
|
askQuestion(question, index = 0, total = 1) {
|
|
@@ -2359,7 +2547,9 @@ export class SshTui {
|
|
|
2359
2547
|
* registry, while the TUI wants the endpoint's current list.
|
|
2360
2548
|
*/
|
|
2361
2549
|
async discoverEndpointModels(provider) {
|
|
2550
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
2362
2551
|
const profile = this.piAiProviderProfile(provider);
|
|
2552
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
2363
2553
|
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
2364
2554
|
? profile.baseURL.trim()
|
|
2365
2555
|
: this.openCodeListingBaseURL(provider);
|
|
@@ -2368,7 +2558,7 @@ export class SshTui {
|
|
|
2368
2558
|
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
2369
2559
|
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
2370
2560
|
? profile.apiKeyEnv.trim()
|
|
2371
|
-
:
|
|
2561
|
+
: source?.apiKeyEnv;
|
|
2372
2562
|
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
2373
2563
|
const llm = this.ctx.get('llm');
|
|
2374
2564
|
if (llm === undefined)
|
|
@@ -2617,9 +2807,14 @@ export class SshTui {
|
|
|
2617
2807
|
this.markDirty();
|
|
2618
2808
|
return;
|
|
2619
2809
|
}
|
|
2810
|
+
if (this.onSwitchSession === undefined) {
|
|
2811
|
+
this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume。' });
|
|
2812
|
+
this.markDirty();
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2620
2815
|
this.pushRow({ kind: 'system', text: `正在切换到会话 ${target}…` });
|
|
2621
2816
|
this.markDirty();
|
|
2622
|
-
await this.onSwitchSession
|
|
2817
|
+
await this.onSwitchSession(target);
|
|
2623
2818
|
return;
|
|
2624
2819
|
}
|
|
2625
2820
|
const persistence = this.ctx.get('sessionPersistence');
|
|
@@ -2639,15 +2834,20 @@ export class SshTui {
|
|
|
2639
2834
|
question: '选择要恢复的历史会话',
|
|
2640
2835
|
options: inspected.map(item => ({
|
|
2641
2836
|
label: item.label,
|
|
2642
|
-
description: `${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
|
|
2837
|
+
description: `${item.unreadable === true ? '⚠ 无法读取 · ' : ''}${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
|
|
2643
2838
|
})),
|
|
2644
2839
|
});
|
|
2645
2840
|
const picked = inspected.find(item => item.label === answer.selected[0]);
|
|
2646
2841
|
if (picked === undefined)
|
|
2647
2842
|
return;
|
|
2843
|
+
if (this.onSwitchSession === undefined) {
|
|
2844
|
+
this.pushRow({ kind: 'error', text: '会话切换回调不可用,无法 /resume。' });
|
|
2845
|
+
this.markDirty();
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2648
2848
|
this.pushRow({ kind: 'system', text: `正在切换到会话 ${picked.id}…` });
|
|
2649
2849
|
this.markDirty();
|
|
2650
|
-
await this.onSwitchSession
|
|
2850
|
+
await this.onSwitchSession(picked.id);
|
|
2651
2851
|
}
|
|
2652
2852
|
/** Current provider route selected for the running agent. */
|
|
2653
2853
|
currentProvider() {
|
|
@@ -2850,6 +3050,13 @@ export class SshTui {
|
|
|
2850
3050
|
this.deleteAtCursor();
|
|
2851
3051
|
return;
|
|
2852
3052
|
}
|
|
3053
|
+
const ss3 = /^\x1bO[A-Z]/u.exec(combined);
|
|
3054
|
+
if (ss3 !== null) {
|
|
3055
|
+
const rest = combined.slice(ss3[0].length);
|
|
3056
|
+
if (rest !== '')
|
|
3057
|
+
this.handlePlainText(rest);
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
2853
3060
|
if (isEscapePrefix(combined)) {
|
|
2854
3061
|
this.escapeBuffer = combined;
|
|
2855
3062
|
this.escapeTimer = setTimeout(() => {
|
|
@@ -2859,7 +3066,12 @@ export class SshTui {
|
|
|
2859
3066
|
if (pending === '\x1b') {
|
|
2860
3067
|
this.handleChar('\x1b');
|
|
2861
3068
|
}
|
|
2862
|
-
else if (pending
|
|
3069
|
+
else if (pending === '\x1bO') {
|
|
3070
|
+
// ESC O without an SS3 final byte is an Alt+O keystroke, not a
|
|
3071
|
+
// function key.
|
|
3072
|
+
this.handlePlainText('O');
|
|
3073
|
+
}
|
|
3074
|
+
else if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO')) {
|
|
2863
3075
|
// An escape sequence that never completed: consume it silently
|
|
2864
3076
|
// instead of treating its ESC byte as a cancel.
|
|
2865
3077
|
}
|
|
@@ -2869,15 +3081,15 @@ export class SshTui {
|
|
|
2869
3081
|
}, 60);
|
|
2870
3082
|
return;
|
|
2871
3083
|
}
|
|
2872
|
-
if (combined.startsWith('\x1b[')) {
|
|
2873
|
-
// Unknown escape sequence
|
|
3084
|
+
if (combined.startsWith('\x1b[') || combined.startsWith('\x1bO')) {
|
|
3085
|
+
// Unknown escape sequence (including SS3 function keys) — consume
|
|
3086
|
+
// without side effects.
|
|
2874
3087
|
return;
|
|
2875
3088
|
}
|
|
2876
|
-
if (combined.startsWith('\x1b')) {
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
this.handlePlainText(rest);
|
|
3089
|
+
if (combined.startsWith('\x1b') && combined.length > 1) {
|
|
3090
|
+
// Alt+<key> sequences: ignore the ESC half instead of triggering cancel,
|
|
3091
|
+
// and type the printable remainder.
|
|
3092
|
+
this.handlePlainText(combined.slice(1));
|
|
2881
3093
|
return;
|
|
2882
3094
|
}
|
|
2883
3095
|
this.handlePlainText(combined);
|
|
@@ -3055,23 +3267,20 @@ export class SshTui {
|
|
|
3055
3267
|
this.closeConfirm('cancel');
|
|
3056
3268
|
return;
|
|
3057
3269
|
}
|
|
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();
|
|
3270
|
+
const key = text.toLowerCase();
|
|
3271
|
+
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
3272
|
+
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
3273
|
+
if (dialog.question.multiSelect === true) {
|
|
3274
|
+
if (dialog.selected.has(index))
|
|
3275
|
+
dialog.selected.delete(index);
|
|
3276
|
+
else
|
|
3070
3277
|
dialog.selected.add(index);
|
|
3071
|
-
}
|
|
3072
|
-
this.markDirty();
|
|
3073
3278
|
}
|
|
3074
|
-
|
|
3279
|
+
else {
|
|
3280
|
+
dialog.selected.clear();
|
|
3281
|
+
dialog.selected.add(index);
|
|
3282
|
+
}
|
|
3283
|
+
this.markDirty();
|
|
3075
3284
|
}
|
|
3076
3285
|
if (text === '\r' || text === '\n') {
|
|
3077
3286
|
const options = dialog.question.options ?? [];
|
|
@@ -3187,8 +3396,10 @@ export class SshTui {
|
|
|
3187
3396
|
return;
|
|
3188
3397
|
}
|
|
3189
3398
|
case 'confirm':
|
|
3399
|
+
if (state.saving)
|
|
3400
|
+
return;
|
|
3190
3401
|
if (text === 'y' || text === 'Y') {
|
|
3191
|
-
|
|
3402
|
+
state.saving = true;
|
|
3192
3403
|
this.input = '';
|
|
3193
3404
|
this.cursor = 0;
|
|
3194
3405
|
void this.saveOnboarding();
|
|
@@ -3345,7 +3556,10 @@ export class SshTui {
|
|
|
3345
3556
|
]);
|
|
3346
3557
|
this.pushRow({ kind: 'system', text: `提供商 ${state.providerId} 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
3347
3558
|
}
|
|
3348
|
-
|
|
3559
|
+
// Only store the key when its provider profile actually made it to
|
|
3560
|
+
// settings; otherwise the saved key points at an unusable route.
|
|
3561
|
+
if (saved)
|
|
3562
|
+
await this.saveCredential(credentials, envRef, state.key);
|
|
3349
3563
|
if (saved) {
|
|
3350
3564
|
const selection = {
|
|
3351
3565
|
provider: state.providerId,
|
|
@@ -3370,7 +3584,10 @@ export class SshTui {
|
|
|
3370
3584
|
}
|
|
3371
3585
|
finally {
|
|
3372
3586
|
this.onboarding = undefined;
|
|
3587
|
+
if (this.dialog?.kind === 'onboarding')
|
|
3588
|
+
this.dialog = undefined;
|
|
3373
3589
|
state.resolve(saved);
|
|
3590
|
+
this.showNextDialog();
|
|
3374
3591
|
this.markDirty();
|
|
3375
3592
|
}
|
|
3376
3593
|
}
|
|
@@ -3398,22 +3615,52 @@ export class SshTui {
|
|
|
3398
3615
|
const home = dshHomeDir();
|
|
3399
3616
|
const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
3400
3617
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
3618
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
3401
3619
|
if (IS_WINDOWS) {
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3620
|
+
let previous = '';
|
|
3621
|
+
try {
|
|
3622
|
+
previous = await readFile(file, 'utf8');
|
|
3405
3623
|
}
|
|
3624
|
+
catch {
|
|
3625
|
+
// File absent: start fresh below.
|
|
3626
|
+
}
|
|
3627
|
+
const preserved = previous.split(/\r?\n/u).filter(Boolean).filter(line => {
|
|
3628
|
+
if (/^@echo off$/iu.test(line.trim()))
|
|
3629
|
+
return false;
|
|
3630
|
+
if (/^rem Generated by dsh-ssh-tui onboarding\.$/iu.test(line.trim()))
|
|
3631
|
+
return false;
|
|
3632
|
+
for (const name of Object.keys(entries)) {
|
|
3633
|
+
if (new RegExp(`^set\\s+"?${escapeRegex(name)}"?=`, 'iu').test(line.trim()))
|
|
3634
|
+
return false;
|
|
3635
|
+
}
|
|
3636
|
+
return true;
|
|
3637
|
+
});
|
|
3638
|
+
const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
|
|
3639
|
+
const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
|
|
3406
3640
|
await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
|
|
3407
3641
|
// Persist for future processes; best-effort, env.cmd remains as a manual fallback.
|
|
3408
3642
|
await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
|
|
3409
3643
|
return;
|
|
3410
3644
|
}
|
|
3411
3645
|
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3646
|
+
let previous = '';
|
|
3647
|
+
try {
|
|
3648
|
+
previous = await readFile(file, 'utf8');
|
|
3649
|
+
}
|
|
3650
|
+
catch {
|
|
3651
|
+
// File absent: start fresh below.
|
|
3415
3652
|
}
|
|
3416
|
-
|
|
3653
|
+
const preserved = previous.split('\n').filter(Boolean).filter(line => {
|
|
3654
|
+
if (line.trim() === '# Generated by dsh-ssh-tui onboarding.')
|
|
3655
|
+
return false;
|
|
3656
|
+
for (const name of Object.keys(entries)) {
|
|
3657
|
+
if (new RegExp(`^export\\s+${escapeRegex(name)}=`).test(line))
|
|
3658
|
+
return false;
|
|
3659
|
+
}
|
|
3660
|
+
return true;
|
|
3661
|
+
});
|
|
3662
|
+
const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
|
|
3663
|
+
await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
|
|
3417
3664
|
await this.ensurePosixEnvHook();
|
|
3418
3665
|
}
|
|
3419
3666
|
/** Persist one variable into the Windows user environment (best-effort). */
|
|
@@ -3426,7 +3673,9 @@ export class SshTui {
|
|
|
3426
3673
|
}
|
|
3427
3674
|
/** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
|
|
3428
3675
|
async ensurePosixEnvHook() {
|
|
3429
|
-
const
|
|
3676
|
+
const envFile = join(dshHomeDir(), 'env.sh');
|
|
3677
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3678
|
+
const sourceLine = `[ -f ${quote(envFile)} ] && . ${quote(envFile)}`;
|
|
3430
3679
|
const marker = '# dsh-ssh-tui launch environment';
|
|
3431
3680
|
const shell = process.env.SHELL ?? '';
|
|
3432
3681
|
const targets = [];
|
|
@@ -3449,7 +3698,7 @@ export class SshTui {
|
|
|
3449
3698
|
if (content.includes(marker))
|
|
3450
3699
|
continue;
|
|
3451
3700
|
const line = relative.endsWith('config.fish')
|
|
3452
|
-
?
|
|
3701
|
+
? `test -f ${quote(envFile)}; and source ${quote(envFile)}`
|
|
3453
3702
|
: sourceLine;
|
|
3454
3703
|
const addition = `${content === '' ? '' : '\n'}${marker}\n${line}\n`;
|
|
3455
3704
|
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
@@ -3609,6 +3858,9 @@ export class SshTui {
|
|
|
3609
3858
|
case 'clear':
|
|
3610
3859
|
this.rows.length = 0;
|
|
3611
3860
|
this.streaming = undefined;
|
|
3861
|
+
this.streamingReasoning = undefined;
|
|
3862
|
+
this.thinkingStartedAt = undefined;
|
|
3863
|
+
this.focusedRow = null;
|
|
3612
3864
|
break;
|
|
3613
3865
|
case 'status':
|
|
3614
3866
|
this.pushRow({
|
|
@@ -3660,7 +3912,13 @@ export class SshTui {
|
|
|
3660
3912
|
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
|
3661
3913
|
}],
|
|
3662
3914
|
agent: this.agent,
|
|
3663
|
-
}).then((answer) =>
|
|
3915
|
+
}).then((answer) => {
|
|
3916
|
+
this.pushRow({ kind: 'system', text: `dialog answer: ${JSON.stringify(answer)}` });
|
|
3917
|
+
this.markDirty();
|
|
3918
|
+
}, (error) => {
|
|
3919
|
+
this.pushRow({ kind: 'error', text: `dialog error: ${errorChain(error)}` });
|
|
3920
|
+
this.markDirty();
|
|
3921
|
+
});
|
|
3664
3922
|
break;
|
|
3665
3923
|
}
|
|
3666
3924
|
default:
|
|
@@ -3670,7 +3928,9 @@ export class SshTui {
|
|
|
3670
3928
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
3671
3929
|
break;
|
|
3672
3930
|
}
|
|
3931
|
+
this.commandAbort?.abort();
|
|
3673
3932
|
const controller = new AbortController();
|
|
3933
|
+
this.commandAbort = controller;
|
|
3674
3934
|
void commands.execute(this.agent, text, controller.signal).then((execution) => {
|
|
3675
3935
|
if (execution === undefined) {
|
|
3676
3936
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
@@ -3687,6 +3947,10 @@ export class SshTui {
|
|
|
3687
3947
|
}
|
|
3688
3948
|
}).catch((error) => {
|
|
3689
3949
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
3950
|
+
}).finally(() => {
|
|
3951
|
+
if (this.commandAbort === controller)
|
|
3952
|
+
this.commandAbort = undefined;
|
|
3953
|
+
this.markDirty();
|
|
3690
3954
|
});
|
|
3691
3955
|
}
|
|
3692
3956
|
break;
|
|
@@ -3696,21 +3960,76 @@ export class SshTui {
|
|
|
3696
3960
|
this.inputFolded = false;
|
|
3697
3961
|
this.markDirty();
|
|
3698
3962
|
}
|
|
3963
|
+
/** Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole. */
|
|
3964
|
+
graphemeBefore(cursor) {
|
|
3965
|
+
if (cursor <= 0)
|
|
3966
|
+
return { start: 0, end: 0 };
|
|
3967
|
+
let previousStart = 0;
|
|
3968
|
+
let previousEnd = 0;
|
|
3969
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
3970
|
+
const start = segment.index;
|
|
3971
|
+
const end = start + segment.segment.length;
|
|
3972
|
+
if (cursor === start)
|
|
3973
|
+
return { start: previousStart, end: previousEnd };
|
|
3974
|
+
if (cursor > start && cursor < end)
|
|
3975
|
+
return { start, end };
|
|
3976
|
+
previousStart = start;
|
|
3977
|
+
previousEnd = end;
|
|
3978
|
+
}
|
|
3979
|
+
return { start: previousStart, end: previousEnd };
|
|
3980
|
+
}
|
|
3981
|
+
/** Grapheme cluster containing or following `cursor`. */
|
|
3982
|
+
graphemeAfter(cursor) {
|
|
3983
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
3984
|
+
const start = segment.index;
|
|
3985
|
+
const end = start + segment.segment.length;
|
|
3986
|
+
if (cursor === start || (cursor > start && cursor < end))
|
|
3987
|
+
return { start, end };
|
|
3988
|
+
}
|
|
3989
|
+
return undefined;
|
|
3990
|
+
}
|
|
3699
3991
|
backspace() {
|
|
3700
3992
|
if (this.cursor === 0)
|
|
3701
3993
|
return;
|
|
3702
|
-
|
|
3703
|
-
this.
|
|
3994
|
+
const range = this.graphemeBefore(this.cursor);
|
|
3995
|
+
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
3996
|
+
this.cursor = range.start;
|
|
3704
3997
|
this.markDirty();
|
|
3705
3998
|
}
|
|
3706
3999
|
deleteAtCursor() {
|
|
3707
|
-
|
|
4000
|
+
const range = this.graphemeAfter(this.cursor);
|
|
4001
|
+
if (range === undefined)
|
|
3708
4002
|
return;
|
|
3709
|
-
this.input = `${this.input.slice(0,
|
|
4003
|
+
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
4004
|
+
this.cursor = range.start;
|
|
3710
4005
|
this.markDirty();
|
|
3711
4006
|
}
|
|
3712
4007
|
moveCursor(delta) {
|
|
3713
|
-
|
|
4008
|
+
if (delta < 0) {
|
|
4009
|
+
let target = 0;
|
|
4010
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4011
|
+
if (segment.index >= this.cursor)
|
|
4012
|
+
break;
|
|
4013
|
+
target = segment.index;
|
|
4014
|
+
}
|
|
4015
|
+
this.cursor = target;
|
|
4016
|
+
}
|
|
4017
|
+
else {
|
|
4018
|
+
let target = this.input.length;
|
|
4019
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
|
|
4020
|
+
const start = segment.index;
|
|
4021
|
+
const end = start + segment.segment.length;
|
|
4022
|
+
if (start > this.cursor) {
|
|
4023
|
+
target = start;
|
|
4024
|
+
break;
|
|
4025
|
+
}
|
|
4026
|
+
if (end > this.cursor) {
|
|
4027
|
+
target = end;
|
|
4028
|
+
break;
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
this.cursor = target;
|
|
4032
|
+
}
|
|
3714
4033
|
this.markDirty();
|
|
3715
4034
|
}
|
|
3716
4035
|
historyBack() {
|