dsh-ssh-tui 0.1.0
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/LICENSE +21 -0
- package/README.md +262 -0
- package/README.zh-CN.md +206 -0
- package/cordis.patch.yml +62 -0
- package/lib/index.js +179 -0
- package/lib/index.js.map +1 -0
- package/lib/picker.js +88 -0
- package/lib/picker.js.map +1 -0
- package/lib/reasoning.js +36 -0
- package/lib/reasoning.js.map +1 -0
- package/lib/session-list.js +76 -0
- package/lib/session-list.js.map +1 -0
- package/lib/startup.js +75 -0
- package/lib/startup.js.map +1 -0
- package/lib/tui.js +3685 -0
- package/lib/tui.js.map +1 -0
- package/lib/types/index.d.ts +33 -0
- package/lib/types/picker.d.ts +21 -0
- package/lib/types/reasoning.d.ts +20 -0
- package/lib/types/session-list.d.ts +26 -0
- package/lib/types/startup.d.ts +41 -0
- package/lib/types/tui.d.ts +365 -0
- package/package.json +119 -0
package/lib/tui.js
ADDED
|
@@ -0,0 +1,3685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-light interactive terminal channel for DeepSeek
|
|
3
|
+
* Harness. It renders the durable session transcript, streams assistant
|
|
4
|
+
* output, shows tool-call cards, answers approval requests and
|
|
5
|
+
* `ask_user_question` prompts from the keyboard, and drives one configured
|
|
6
|
+
* agent with followup/steer.
|
|
7
|
+
*
|
|
8
|
+
* The renderer uses plain ANSI and a throttled full repaint, which keeps it
|
|
9
|
+
* predictable over slow SSH links and avoids terminal-library dependency
|
|
10
|
+
* drift inside the plugin.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
18
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
19
|
+
import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
20
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
21
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
22
|
+
import { formatSessionTime, listResumableSessions } from './session-list.js';
|
|
23
|
+
import { defaultReasoningEffort } from './reasoning.js';
|
|
24
|
+
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
25
|
+
const PROVIDER_TEMPLATES = {
|
|
26
|
+
official: {
|
|
27
|
+
label: 'DeepSeek 官方',
|
|
28
|
+
defaultId: 'deepseek-official',
|
|
29
|
+
defaultBaseUrl: 'https://api.deepseek.com',
|
|
30
|
+
defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
|
|
31
|
+
},
|
|
32
|
+
'opencode-go': {
|
|
33
|
+
label: 'OpenCode Go(opencode.ai/zen/go)',
|
|
34
|
+
defaultId: 'opencode-go',
|
|
35
|
+
defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
|
|
36
|
+
api: 'openai-responses',
|
|
37
|
+
defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
|
38
|
+
},
|
|
39
|
+
'openai-completions': {
|
|
40
|
+
label: '自定义 OpenAI 兼容网关(Completions)',
|
|
41
|
+
defaultId: 'my-gateway',
|
|
42
|
+
defaultBaseUrl: '',
|
|
43
|
+
api: 'openai-completions',
|
|
44
|
+
defaultModels: ['deepseek-v4-flash'],
|
|
45
|
+
},
|
|
46
|
+
'openai-responses': {
|
|
47
|
+
label: '自定义 OpenAI Responses 网关',
|
|
48
|
+
defaultId: 'my-responses',
|
|
49
|
+
defaultBaseUrl: '',
|
|
50
|
+
api: 'openai-responses',
|
|
51
|
+
defaultModels: ['deepseek-v4-flash'],
|
|
52
|
+
},
|
|
53
|
+
'anthropic-messages': {
|
|
54
|
+
label: 'Anthropic Messages 兼容网关',
|
|
55
|
+
defaultId: 'my-anthropic',
|
|
56
|
+
defaultBaseUrl: '',
|
|
57
|
+
api: 'anthropic-messages',
|
|
58
|
+
defaultModels: ['deepseek-v4-flash'],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const RENDER_INTERVAL_MS = 120;
|
|
62
|
+
const WAIT_INDICATOR_MS = 8000;
|
|
63
|
+
const STALL_WARNING_MS = 60000;
|
|
64
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
65
|
+
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
66
|
+
const MAX_TRANSCRIPT_ROWS = 5000;
|
|
67
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
68
|
+
function dshHomeDir() {
|
|
69
|
+
return process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
70
|
+
}
|
|
71
|
+
function displayDshPath(file) {
|
|
72
|
+
const home = dshHomeDir();
|
|
73
|
+
if (IS_WINDOWS) {
|
|
74
|
+
const profile = process.env.USERPROFILE;
|
|
75
|
+
if (profile !== undefined && home.toLowerCase().startsWith(profile.toLowerCase())) {
|
|
76
|
+
const rest = home.slice(profile.length);
|
|
77
|
+
return `%USERPROFILE%${rest}\\${file}`.replaceAll('/', '\\');
|
|
78
|
+
}
|
|
79
|
+
return `${home}\\${file}`.replaceAll('/', '\\');
|
|
80
|
+
}
|
|
81
|
+
return `~/.dsh/${file}`;
|
|
82
|
+
}
|
|
83
|
+
const DSH_ENV_FILE = join(dshHomeDir(), IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
84
|
+
const DEEPSEEK_LOGO_VARIANTS = [
|
|
85
|
+
{
|
|
86
|
+
width: 52,
|
|
87
|
+
lines: [
|
|
88
|
+
'',
|
|
89
|
+
'',
|
|
90
|
+
' .:',
|
|
91
|
+
' ...... .-=*###- .%%.',
|
|
92
|
+
' -+*%%%@@@@%%@@@@@@. =@%%*-. -*-.',
|
|
93
|
+
' :*%@@@@@@%%%%@@%%%%%%*: -@%@@@%= -+***%@@.',
|
|
94
|
+
' +@@@%%%%%%%%%%%%%%%@@%@@#= #@%%%@@%@@@@@@@=',
|
|
95
|
+
' .#@%%%%%%%%%%%%@@@@@@%%@%%@@%= *@@%%%@%%@@@%=',
|
|
96
|
+
' #@@@@@@@@@@@%%%%%%@@@@%@%%%%%@%= .*%%%%%%#*-',
|
|
97
|
+
' =@%*=---=+*#%@@@%%%%%@@@%@@%@@@@@%+: *@%%%-',
|
|
98
|
+
' #%@- .-+%@@@%%%%%%%+: .=#@%@@@%@%%%%.',
|
|
99
|
+
' #%@* :*%@%%@%%%+++ -%@%%@%@%%*',
|
|
100
|
+
' #%%%. .+@@%%%@%%% .#%%%%%%@:',
|
|
101
|
+
' =@%@* :#@%%@@@%#=--#%%%%%@+',
|
|
102
|
+
' %@%@+ +@@%%%%@@@@@%@%%@*',
|
|
103
|
+
' :%@%@* -%@%%%%%%%%@%@@+',
|
|
104
|
+
' :%@%@%- -+-. .*@@%%%%%@%@#-',
|
|
105
|
+
' .*@@@@#-. :%@%*- -%@@%%%%%*',
|
|
106
|
+
' :#@@@@%*=:::#%%@@%+: -*@@@@@%#*=:',
|
|
107
|
+
' :+%@@@@@@@@%%%%%@@%#*+**+*##%%%#=',
|
|
108
|
+
' :+*%%@@@@@@@@@@@%#+:',
|
|
109
|
+
' .:-=====--:.',
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
width: 44,
|
|
114
|
+
lines: [
|
|
115
|
+
'',
|
|
116
|
+
'',
|
|
117
|
+
' .:-==. :#.',
|
|
118
|
+
' :=*#######%%@@@- *@%=: .=:',
|
|
119
|
+
' .+%@@@@@@@@@@@@%%%+: *@%@@#::=+**%@:',
|
|
120
|
+
' +%@@%%%%%%%%%%%%%@%@@#- .%@%%@%@@@@@@+',
|
|
121
|
+
' *@@@@@@@%%%%%%%@@%%@%%@@#- .+%%%@@@@%#-',
|
|
122
|
+
' =@%####%%@@@@%%%%%@@@@@@@@@#- =%%%#=-.',
|
|
123
|
+
' %%* .:-*%@@@%%%%%%+-+#@@@%#%@%%=',
|
|
124
|
+
'.%%# :*%@%%%%%=+ =%%@@@%%@:',
|
|
125
|
+
' %%@- .+@@%%@#@- :%%%%%@*',
|
|
126
|
+
' +@%%. :#@%%@%%*++%%%%@%.',
|
|
127
|
+
' .%@%#. *@@%%@@@@@%%@%.',
|
|
128
|
+
' .%@@%- .:. =%@%%%%%%@@*.',
|
|
129
|
+
' .*@@@#- .%%*=. .*@@@%%%%-',
|
|
130
|
+
' -#@@@%+-::*@@@%*- :+%@@@@#*=.',
|
|
131
|
+
' :+#@@@@@@@@%@@@@%*++-++****:',
|
|
132
|
+
' :=+*#%%%%%##*+-.',
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
width: 36,
|
|
137
|
+
lines: [
|
|
138
|
+
'',
|
|
139
|
+
'',
|
|
140
|
+
' .: ::',
|
|
141
|
+
' :-++++++*#%%- %%-. :.',
|
|
142
|
+
' .+%@@@@@@@@@@@%=. %@@%+:=++#@=',
|
|
143
|
+
' =%@@%%%%%%%%%%%%@%*: -%@@@@@@@@+',
|
|
144
|
+
' =@@@@@@@@@%%%%%@@%@@@*: +%%%%#+:',
|
|
145
|
+
'.%%-..:-=*%@@@%%%%%**%@@#=+%%%',
|
|
146
|
+
':@%. :+%@%%%%=: +%@@@%%*',
|
|
147
|
+
'.%%+ .+@@%%%# =%%%%@.',
|
|
148
|
+
' *@%- :%@%@@%##@%%@=',
|
|
149
|
+
' .#@%= *@@%@@@%@%-',
|
|
150
|
+
' .*@@#- **=. -%@@%%%*',
|
|
151
|
+
' -#@@%+-:+@@%*- .=%@@@#*=',
|
|
152
|
+
' :+#@@@@@@@@@@#+=:-====',
|
|
153
|
+
' .:-=+++=-:.',
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
width: 28,
|
|
158
|
+
lines: [
|
|
159
|
+
'',
|
|
160
|
+
'',
|
|
161
|
+
' .:----=+*: :#: .',
|
|
162
|
+
' .+#%@@@@@@@@=. -@@#--++%+',
|
|
163
|
+
' :%@@@@%%%%%%%@@+. =%@@@@@+',
|
|
164
|
+
'.%#++*#%@@@%%@%%@@+:=%%+:.',
|
|
165
|
+
'=%- :+%@%%#=.+@@%%%',
|
|
166
|
+
':@# .+@@%%- *%%@+',
|
|
167
|
+
' *@* :%@%@@%@@*',
|
|
168
|
+
' *@#: :=: +%@@%%-',
|
|
169
|
+
' -#@%+-+@@#=.=#%@%+-',
|
|
170
|
+
' :+#%@%%@@#+:.::-:',
|
|
171
|
+
' ...',
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
width: 20,
|
|
176
|
+
lines: [
|
|
177
|
+
'',
|
|
178
|
+
' ...:-. -.',
|
|
179
|
+
' +#%%%%@@= +@*-+*+',
|
|
180
|
+
'.#@%@@@@%%@%+.+@@#+',
|
|
181
|
+
'*# .-+%@%#-*%*@=',
|
|
182
|
+
'=@. +@%*-%@%.',
|
|
183
|
+
' *%- . :#@@@*.',
|
|
184
|
+
' -##++@%=-*#%+.',
|
|
185
|
+
' :=++*+=. .',
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
const LOCAL_COMMANDS = [
|
|
190
|
+
{ name: 'help', description: 'show all available commands' },
|
|
191
|
+
{ name: 'model', description: 'select model and reasoning effort (same provider)' },
|
|
192
|
+
{ name: 'mode', description: 'switch agent mode / preset (standard, PTC, minimal, ...)' },
|
|
193
|
+
{ name: 'quit', description: 'exit the TUI' },
|
|
194
|
+
{ name: 'exit', description: 'exit the TUI' },
|
|
195
|
+
{ name: 'clear', description: 'clear the transcript view' },
|
|
196
|
+
{ name: 'status', description: 'show session, provider and model status' },
|
|
197
|
+
{ name: 'usage', description: 'show OpenCode Zen billing / Go quota usage' },
|
|
198
|
+
{ name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
|
|
199
|
+
{ name: 'subagents', description: 'list active subagents' },
|
|
200
|
+
{ name: 'resume', description: 'resume a past session (empty = session picker)' },
|
|
201
|
+
{ name: 'setup', description: 're-open provider / API key setup' },
|
|
202
|
+
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
203
|
+
];
|
|
204
|
+
function displayWidth(text) {
|
|
205
|
+
let width = 0;
|
|
206
|
+
for (const char of text) {
|
|
207
|
+
const cp = char.codePointAt(0) ?? 0;
|
|
208
|
+
const wide = (cp >= 0x1100 && cp <= 0x115f) ||
|
|
209
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
210
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
211
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
212
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
213
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
214
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
215
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
216
|
+
(cp >= 0x20000 && cp <= 0x3fffd);
|
|
217
|
+
width += wide ? 2 : 1;
|
|
218
|
+
}
|
|
219
|
+
return width;
|
|
220
|
+
}
|
|
221
|
+
function wrap(text, width) {
|
|
222
|
+
const lines = [];
|
|
223
|
+
for (const rawLine of text.split('\n')) {
|
|
224
|
+
if (rawLine === '') {
|
|
225
|
+
lines.push('');
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
let rest = rawLine;
|
|
229
|
+
while (displayWidth(rest) > width) {
|
|
230
|
+
let cut = 0;
|
|
231
|
+
let used = 0;
|
|
232
|
+
for (const char of rest) {
|
|
233
|
+
const charWidth = displayWidth(char);
|
|
234
|
+
if (used + charWidth > width)
|
|
235
|
+
break;
|
|
236
|
+
used += charWidth;
|
|
237
|
+
cut += char.length;
|
|
238
|
+
}
|
|
239
|
+
if (cut === 0)
|
|
240
|
+
cut = 1;
|
|
241
|
+
lines.push(rest.slice(0, cut));
|
|
242
|
+
rest = rest.slice(cut);
|
|
243
|
+
}
|
|
244
|
+
lines.push(rest);
|
|
245
|
+
}
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
248
|
+
function truncate(text, maxLines) {
|
|
249
|
+
const lines = text.split('\n');
|
|
250
|
+
if (lines.length <= maxLines)
|
|
251
|
+
return text;
|
|
252
|
+
const head = lines.slice(0, Math.max(1, maxLines - 1));
|
|
253
|
+
const tail = lines.slice(-1);
|
|
254
|
+
return [...head, `… ${lines.length - head.length - 1} more line(s) …`, ...tail].join('\n');
|
|
255
|
+
}
|
|
256
|
+
const INLINE_MARKDOWN_PATTERN = /(\*\*[^*\n]+\*\*)|(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\n]+\))|(\*[^*\n]+\*)|(_[^_\n]+_)/gu;
|
|
257
|
+
/** Parse one line's bold / italic / inline-code / link spans. */
|
|
258
|
+
function parseInlineMarkdown(line) {
|
|
259
|
+
const segments = [];
|
|
260
|
+
let last = 0;
|
|
261
|
+
for (const match of line.matchAll(INLINE_MARKDOWN_PATTERN)) {
|
|
262
|
+
const index = match.index;
|
|
263
|
+
if (index > last)
|
|
264
|
+
segments.push({ kind: 'text', text: line.slice(last, index) });
|
|
265
|
+
const token = match[0];
|
|
266
|
+
if (match[1] !== undefined) {
|
|
267
|
+
segments.push({ kind: 'bold', text: token.slice(2, -2) });
|
|
268
|
+
}
|
|
269
|
+
else if (match[2] !== undefined) {
|
|
270
|
+
segments.push({ kind: 'code', text: token.slice(1, -1) });
|
|
271
|
+
}
|
|
272
|
+
else if (match[3] !== undefined) {
|
|
273
|
+
const labelEnd = token.indexOf('](');
|
|
274
|
+
const label = token.slice(1, labelEnd);
|
|
275
|
+
const url = token.slice(labelEnd + 2, -1);
|
|
276
|
+
segments.push({ kind: 'link', text: label });
|
|
277
|
+
if (url !== '')
|
|
278
|
+
segments.push({ kind: 'muted', text: ` (${url})` });
|
|
279
|
+
}
|
|
280
|
+
else if (match[4] !== undefined) {
|
|
281
|
+
segments.push({ kind: 'italic', text: token.slice(1, -1) });
|
|
282
|
+
}
|
|
283
|
+
else if (match[5] !== undefined) {
|
|
284
|
+
segments.push({ kind: 'italic', text: token.slice(1, -1) });
|
|
285
|
+
}
|
|
286
|
+
last = index + token.length;
|
|
287
|
+
}
|
|
288
|
+
if (last < line.length)
|
|
289
|
+
segments.push({ kind: 'text', text: line.slice(last) });
|
|
290
|
+
if (segments.length === 0)
|
|
291
|
+
segments.push({ kind: 'text', text: line });
|
|
292
|
+
return segments;
|
|
293
|
+
}
|
|
294
|
+
function markdownSegmentWidth(segments) {
|
|
295
|
+
return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
|
|
296
|
+
}
|
|
297
|
+
/** Wrap styled inline segments into visual rows, carrying a prefix only on row one. */
|
|
298
|
+
function wrapMarkdownSegments(segments, width, prefixSegments = []) {
|
|
299
|
+
const limit = Math.max(1, width);
|
|
300
|
+
const lines = [];
|
|
301
|
+
let current = [...prefixSegments];
|
|
302
|
+
let used = markdownSegmentWidth(current);
|
|
303
|
+
for (const segment of segments) {
|
|
304
|
+
let rest = segment.text;
|
|
305
|
+
while (rest !== '') {
|
|
306
|
+
const available = limit - used;
|
|
307
|
+
if (available <= 0) {
|
|
308
|
+
lines.push(current);
|
|
309
|
+
current = [];
|
|
310
|
+
used = 0;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const slice = forwardSliceByWidth(rest, available);
|
|
314
|
+
let chunk = slice.text;
|
|
315
|
+
if (chunk === '') {
|
|
316
|
+
// A wide character does not fit the remaining cell; take one code
|
|
317
|
+
// point so the loop always makes progress. The terminal wraps it.
|
|
318
|
+
chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
|
|
319
|
+
}
|
|
320
|
+
current.push({ kind: segment.kind, text: chunk });
|
|
321
|
+
used += displayWidth(chunk);
|
|
322
|
+
rest = rest.slice(chunk.length);
|
|
323
|
+
if (rest !== '') {
|
|
324
|
+
lines.push(current);
|
|
325
|
+
current = [];
|
|
326
|
+
used = 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (current.length > 0 || lines.length === 0)
|
|
331
|
+
lines.push(current);
|
|
332
|
+
return lines.map(line => line.length === 0 ? [{ kind: 'text', text: '' }] : line);
|
|
333
|
+
}
|
|
334
|
+
function markdownSegmentCode(kind) {
|
|
335
|
+
switch (kind) {
|
|
336
|
+
case 'bold': return '1;97';
|
|
337
|
+
case 'italic': return '3;37';
|
|
338
|
+
case 'code': return '36';
|
|
339
|
+
case 'link': return '4;36';
|
|
340
|
+
case 'muted': return '2;37';
|
|
341
|
+
default: return '';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function markdownBaseCode(kind) {
|
|
345
|
+
switch (kind) {
|
|
346
|
+
case 'heading1': return '1;4;97';
|
|
347
|
+
case 'heading2': return '1;4;36';
|
|
348
|
+
case 'heading3': return '1;36';
|
|
349
|
+
case 'code': return '36';
|
|
350
|
+
case 'quote': return '3;37';
|
|
351
|
+
case 'rule': return '90';
|
|
352
|
+
default: return '1;37';
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
|
|
356
|
+
function renderMarkdownBlockLine(block, color) {
|
|
357
|
+
if (!color)
|
|
358
|
+
return block.segments.map(segment => segment.text).join('');
|
|
359
|
+
const base = markdownBaseCode(block.base);
|
|
360
|
+
let out = `\x1b[${base}m`;
|
|
361
|
+
for (const segment of block.segments) {
|
|
362
|
+
const code = markdownSegmentCode(segment.kind);
|
|
363
|
+
if (code === '') {
|
|
364
|
+
out += segment.text;
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
out += `\x1b[${code}m${segment.text}\x1b[${base}m`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return `${out}\x1b[0m`;
|
|
371
|
+
}
|
|
372
|
+
/** Enlarge H1 text visually: fullwidth ASCII and spaced CJK glyphs. */
|
|
373
|
+
function expandHeadingText(text) {
|
|
374
|
+
let out = '';
|
|
375
|
+
for (const char of text) {
|
|
376
|
+
const cp = char.codePointAt(0) ?? 0;
|
|
377
|
+
if (cp >= 0x21 && cp <= 0x7e) {
|
|
378
|
+
out += String.fromCodePoint(0xff01 + cp - 0x21);
|
|
379
|
+
}
|
|
380
|
+
else if (char.trim() === '') {
|
|
381
|
+
out += ' ';
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
out += `${char} `;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
function headingSegments(text, level) {
|
|
390
|
+
const segments = parseInlineMarkdown(text);
|
|
391
|
+
if (level !== 1)
|
|
392
|
+
return segments;
|
|
393
|
+
return segments.map(segment => segment.kind === 'code' || segment.kind === 'link' || segment.kind === 'muted'
|
|
394
|
+
? segment
|
|
395
|
+
: { kind: segment.kind, text: expandHeadingText(segment.text) });
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Render workspace markdown into width-bounded terminal rows. Assistant
|
|
399
|
+
* replies get a bold-white base; code blocks, headings, quotes, lists, rules,
|
|
400
|
+
* links and inline spans keep their own ANSI treatment.
|
|
401
|
+
*/
|
|
402
|
+
export function renderMarkdownLines(text, width, color) {
|
|
403
|
+
const lines = [];
|
|
404
|
+
let inFence = false;
|
|
405
|
+
for (const raw of text.split('\n')) {
|
|
406
|
+
const fence = /^```([^\n]*)$/u.exec(raw.trim());
|
|
407
|
+
if (fence !== null) {
|
|
408
|
+
inFence = !inFence;
|
|
409
|
+
lines.push(renderMarkdownBlockLine({
|
|
410
|
+
base: 'code',
|
|
411
|
+
segments: [{ kind: 'text', text: `\`\`\`${fence[1] ?? ''}` }],
|
|
412
|
+
}, color));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (inFence) {
|
|
416
|
+
if (raw === '') {
|
|
417
|
+
lines.push('');
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
for (const line of wrap(raw, width)) {
|
|
421
|
+
lines.push(renderMarkdownBlockLine({
|
|
422
|
+
base: 'code',
|
|
423
|
+
segments: [{ kind: 'text', text: line }],
|
|
424
|
+
}, color));
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
|
|
429
|
+
if (heading !== null) {
|
|
430
|
+
// The hashes are markdown syntax, not content: replace them with
|
|
431
|
+
// heading style. Levels differ visually: H1 is enlarged and
|
|
432
|
+
// underlined, H2 underlined, H3 colored, H4+ bold white.
|
|
433
|
+
const level = Math.min(6, (heading[1] ?? '#').length);
|
|
434
|
+
const base = level === 1
|
|
435
|
+
? 'heading1'
|
|
436
|
+
: level === 2
|
|
437
|
+
? 'heading2'
|
|
438
|
+
: level === 3
|
|
439
|
+
? 'heading3'
|
|
440
|
+
: 'assistant';
|
|
441
|
+
if (level === 1 && lines.at(-1) !== '')
|
|
442
|
+
lines.push('');
|
|
443
|
+
for (const segments of wrapMarkdownSegments(headingSegments(heading[2] ?? '', level), width)) {
|
|
444
|
+
lines.push(renderMarkdownBlockLine({ base, segments }, color));
|
|
445
|
+
}
|
|
446
|
+
if (level === 1)
|
|
447
|
+
lines.push('');
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
|
|
451
|
+
lines.push(renderMarkdownBlockLine({
|
|
452
|
+
base: 'rule',
|
|
453
|
+
segments: [{ kind: 'text', text: '─'.repeat(Math.max(1, width)) }],
|
|
454
|
+
}, color));
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const quote = /^(\s*)>\s?(.*)$/u.exec(raw);
|
|
458
|
+
if (quote !== null) {
|
|
459
|
+
const indent = quote[1] ?? '';
|
|
460
|
+
const prefix = `${indent}│ `;
|
|
461
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(quote[2] ?? ''), width, [{ kind: 'text', text: prefix }])) {
|
|
462
|
+
lines.push(renderMarkdownBlockLine({ base: 'quote', segments }, color));
|
|
463
|
+
}
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const list = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/u.exec(raw);
|
|
467
|
+
if (list !== null) {
|
|
468
|
+
const indent = list[1] ?? '';
|
|
469
|
+
const marker = list[2] ?? '-';
|
|
470
|
+
const prefix = `${indent}${marker} `;
|
|
471
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(list[3] ?? ''), width, [{ kind: 'text', text: prefix }])) {
|
|
472
|
+
lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (raw === '') {
|
|
477
|
+
lines.push('');
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(raw), width)) {
|
|
481
|
+
lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return lines;
|
|
485
|
+
}
|
|
486
|
+
/** Cut one line to fit a width, appending an ellipsis when truncated. */
|
|
487
|
+
function truncateToWidth(text, width) {
|
|
488
|
+
if (displayWidth(text) <= width)
|
|
489
|
+
return text;
|
|
490
|
+
let cut = 0;
|
|
491
|
+
let used = 0;
|
|
492
|
+
for (const char of text) {
|
|
493
|
+
const charWidth = displayWidth(char);
|
|
494
|
+
if (used + charWidth > width - 1)
|
|
495
|
+
break;
|
|
496
|
+
used += charWidth;
|
|
497
|
+
cut += char.length;
|
|
498
|
+
}
|
|
499
|
+
if (cut === 0)
|
|
500
|
+
cut = 1;
|
|
501
|
+
return `${text.slice(0, cut)}…`;
|
|
502
|
+
}
|
|
503
|
+
/** Slice up to `maxWidth` display columns from the beginning of `text`. */
|
|
504
|
+
function forwardSliceByWidth(text, maxWidth) {
|
|
505
|
+
let cut = 0;
|
|
506
|
+
let used = 0;
|
|
507
|
+
for (const char of text) {
|
|
508
|
+
const charWidth = displayWidth(char);
|
|
509
|
+
if (used + charWidth > maxWidth)
|
|
510
|
+
break;
|
|
511
|
+
used += charWidth;
|
|
512
|
+
cut += char.length;
|
|
513
|
+
}
|
|
514
|
+
return { text: text.slice(0, cut), width: used };
|
|
515
|
+
}
|
|
516
|
+
/** Slice up to `maxWidth` display columns ending at `end` in `text`. */
|
|
517
|
+
function backwardSliceByWidth(text, end, maxWidth) {
|
|
518
|
+
if (end <= 0 || maxWidth <= 0)
|
|
519
|
+
return { start: end, width: 0 };
|
|
520
|
+
const chars = Array.from(text.slice(0, end));
|
|
521
|
+
let used = 0;
|
|
522
|
+
let firstIncluded = chars.length;
|
|
523
|
+
for (let index = chars.length - 1; index >= 0; index--) {
|
|
524
|
+
const charWidth = displayWidth(chars[index] ?? '');
|
|
525
|
+
if (used + charWidth > maxWidth)
|
|
526
|
+
break;
|
|
527
|
+
used += charWidth;
|
|
528
|
+
firstIncluded = index;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
start: chars.slice(0, firstIncluded).join('').length,
|
|
532
|
+
width: used,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Fold a long single-line input into one terminal row around the cursor.
|
|
537
|
+
* Only the *display* is clipped; the caller keeps the original `input` intact
|
|
538
|
+
* for editing and submission.
|
|
539
|
+
*/
|
|
540
|
+
export function foldInputView(input, cursor, maxWidth) {
|
|
541
|
+
const width = Math.max(1, maxWidth);
|
|
542
|
+
const totalWidth = displayWidth(input);
|
|
543
|
+
const cursorOffset = displayWidth(input.slice(0, cursor));
|
|
544
|
+
if (totalWidth <= width) {
|
|
545
|
+
return { text: input, cursorOffset, folded: false };
|
|
546
|
+
}
|
|
547
|
+
const before = cursorOffset;
|
|
548
|
+
const after = totalWidth - cursorOffset;
|
|
549
|
+
const leftFolded = before > 0;
|
|
550
|
+
const rightFolded = after > 0;
|
|
551
|
+
const markers = (leftFolded ? 1 : 0) + (rightFolded ? 1 : 0);
|
|
552
|
+
const available = Math.max(1, width - markers);
|
|
553
|
+
let beforeBudget = Math.min(before, Math.ceil(available / 2));
|
|
554
|
+
let afterBudget = Math.min(after, available - beforeBudget);
|
|
555
|
+
// If the tail is shorter than its budget, spend the spare columns on the
|
|
556
|
+
// side before the cursor so the cursor stays visible near its true offset.
|
|
557
|
+
beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
|
|
558
|
+
const beforeSlice = backwardSliceByWidth(input, cursor, beforeBudget);
|
|
559
|
+
const afterSlice = forwardSliceByWidth(input.slice(cursor), afterBudget);
|
|
560
|
+
const beforeText = input.slice(beforeSlice.start, cursor);
|
|
561
|
+
return {
|
|
562
|
+
text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
|
|
563
|
+
cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
|
|
564
|
+
folded: true,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
|
|
568
|
+
const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
569
|
+
/**
|
|
570
|
+
* Classify the currently selected provider as an OpenCode route. Built-in
|
|
571
|
+
* `opencode`/`opencode-go` ids are recognized directly, and custom llm-pi-ai
|
|
572
|
+
* routes are recognized by their `opencode.ai` base URL.
|
|
573
|
+
*/
|
|
574
|
+
export function openCodeSourceFor(provider, llmPiAiSection) {
|
|
575
|
+
const section = llmPiAiSection;
|
|
576
|
+
const profile = section?.providers?.[provider];
|
|
577
|
+
const baseURL = typeof profile?.baseURL === 'string' ? profile.baseURL : undefined;
|
|
578
|
+
const lowerBase = baseURL?.toLowerCase() ?? '';
|
|
579
|
+
const isGo = provider === 'opencode-go' || lowerBase.includes('opencode.ai/zen/go');
|
|
580
|
+
const isZen = provider === 'opencode' || (lowerBase.includes('opencode.ai/zen') && !isGo);
|
|
581
|
+
if (!isGo && !isZen)
|
|
582
|
+
return null;
|
|
583
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
584
|
+
? profile.apiKeyEnv
|
|
585
|
+
: provider === 'opencode' || provider === 'opencode-go'
|
|
586
|
+
? 'OPENCODE_API_KEY'
|
|
587
|
+
: `${provider.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
588
|
+
const label = typeof profile?.displayName === 'string' && profile.displayName.trim() !== ''
|
|
589
|
+
? profile.displayName
|
|
590
|
+
: isGo ? 'OpenCode Go' : 'OpenCode Zen';
|
|
591
|
+
return {
|
|
592
|
+
provider,
|
|
593
|
+
flavor: isGo ? 'go' : 'zen',
|
|
594
|
+
label,
|
|
595
|
+
apiKeyEnv,
|
|
596
|
+
...(baseURL === undefined ? {} : { baseURL }),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function openCodeGoUsageWindow(value) {
|
|
600
|
+
if (typeof value !== 'object' || value === null)
|
|
601
|
+
return undefined;
|
|
602
|
+
const raw = value;
|
|
603
|
+
return {
|
|
604
|
+
...(typeof raw.status === 'string' ? { status: raw.status } : {}),
|
|
605
|
+
...(typeof raw.percent === 'number' && Number.isFinite(raw.percent) ? { percent: raw.percent } : {}),
|
|
606
|
+
...(typeof raw.resetsAt === 'string' ? { resetsAt: raw.resetsAt } : {}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** A days/hours/minutes/seconds relative duration for quota reset times. */
|
|
610
|
+
function formatRelativeDuration(ms) {
|
|
611
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
612
|
+
if (seconds < 60)
|
|
613
|
+
return `${seconds}s`;
|
|
614
|
+
if (seconds < 3600)
|
|
615
|
+
return `${Math.floor(seconds / 60)}m${seconds % 60}s`;
|
|
616
|
+
if (seconds < 86400) {
|
|
617
|
+
return `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`;
|
|
618
|
+
}
|
|
619
|
+
return `${Math.floor(seconds / 86400)}d${Math.floor(seconds % 86400 / 3600)}h`;
|
|
620
|
+
}
|
|
621
|
+
/** One compact `████░░ 40.0% · 正常 · 约 2m 后重置` line for a Go limit. */
|
|
622
|
+
function formatOpenCodeGoWindow(label, value) {
|
|
623
|
+
const window = openCodeGoUsageWindow(value);
|
|
624
|
+
const percent = window?.percent === undefined
|
|
625
|
+
? null
|
|
626
|
+
: Math.max(0, Math.min(100, window.percent));
|
|
627
|
+
const state = window?.status === 'rate-limited'
|
|
628
|
+
? '已限流'
|
|
629
|
+
: window?.status === 'ok'
|
|
630
|
+
? '正常'
|
|
631
|
+
: window?.status ?? '未知状态';
|
|
632
|
+
const parts = [label];
|
|
633
|
+
if (percent !== null) {
|
|
634
|
+
const barWidth = 16;
|
|
635
|
+
const filled = Math.round(percent / 100 * barWidth);
|
|
636
|
+
parts.push(`${'█'.repeat(filled)}${'░'.repeat(barWidth - filled)} ${percent.toFixed(1)}%`);
|
|
637
|
+
}
|
|
638
|
+
parts.push(state);
|
|
639
|
+
if (window?.resetsAt !== undefined) {
|
|
640
|
+
const reset = new Date(window.resetsAt);
|
|
641
|
+
if (!Number.isNaN(reset.getTime())) {
|
|
642
|
+
const until = reset.getTime() - Date.now();
|
|
643
|
+
parts.push(until > 0
|
|
644
|
+
? `约 ${formatRelativeDuration(until)} 后重置(${reset.toLocaleString()})`
|
|
645
|
+
: `已于 ${reset.toLocaleString()} 重置`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return ` ${parts.join(' · ')}`;
|
|
649
|
+
}
|
|
650
|
+
/** Render the OpenCode Go quota payload as a transcript block. */
|
|
651
|
+
export function formatOpenCodeGoUsage(payload, source) {
|
|
652
|
+
const raw = payload;
|
|
653
|
+
const usage = raw?.usage;
|
|
654
|
+
if (usage === null || usage === undefined) {
|
|
655
|
+
throw new Error('额度接口返回格式无法识别');
|
|
656
|
+
}
|
|
657
|
+
return [
|
|
658
|
+
`OpenCode Go 额度(${source.provider})`,
|
|
659
|
+
formatOpenCodeGoWindow('滚动 5 小时', usage.rolling),
|
|
660
|
+
formatOpenCodeGoWindow('本周', usage.weekly),
|
|
661
|
+
formatOpenCodeGoWindow('本月', usage.monthly),
|
|
662
|
+
].join('\n');
|
|
663
|
+
}
|
|
664
|
+
/** Extract a safe human-readable message from an OpenCode error payload. */
|
|
665
|
+
function openCodeApiErrorMessage(payload) {
|
|
666
|
+
if (typeof payload !== 'object' || payload === null)
|
|
667
|
+
return '';
|
|
668
|
+
const raw = payload;
|
|
669
|
+
const error = raw.error;
|
|
670
|
+
if (typeof error === 'string' && error.trim() !== '')
|
|
671
|
+
return error.trim();
|
|
672
|
+
if (typeof error === 'object' && error !== null) {
|
|
673
|
+
const message = error.message;
|
|
674
|
+
if (typeof message === 'string' && message.trim() !== '')
|
|
675
|
+
return message.trim();
|
|
676
|
+
}
|
|
677
|
+
if (typeof raw.message === 'string' && raw.message.trim() !== '')
|
|
678
|
+
return raw.message.trim();
|
|
679
|
+
return '';
|
|
680
|
+
}
|
|
681
|
+
/** Whether `text` could still grow into a recognized escape sequence. */
|
|
682
|
+
function isEscapePrefix(text) {
|
|
683
|
+
if (text === '\x1b')
|
|
684
|
+
return true;
|
|
685
|
+
if (!text.startsWith('\x1b'))
|
|
686
|
+
return false;
|
|
687
|
+
if (text === '\x1b[')
|
|
688
|
+
return true;
|
|
689
|
+
if (/^\x1b\[[A-D]$/u.test(text))
|
|
690
|
+
return true;
|
|
691
|
+
if (/^\x1b\[[HF]$/u.test(text))
|
|
692
|
+
return true;
|
|
693
|
+
if (/^\x1b\[\d~?$/u.test(text))
|
|
694
|
+
return true;
|
|
695
|
+
if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
|
|
696
|
+
return true;
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
/** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
|
|
700
|
+
function parseJsonArgs(args) {
|
|
701
|
+
try {
|
|
702
|
+
const parsed = JSON.parse(args);
|
|
703
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
704
|
+
? parsed
|
|
705
|
+
: null;
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** A short scalar rendering of one argument value, or null for objects/arrays. */
|
|
712
|
+
function scalarText(value) {
|
|
713
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
714
|
+
return String(value);
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
/** Prefer the fields a human scans for; fall back to the first scalar pairs. */
|
|
719
|
+
function friendlyArgsSummary(name, args) {
|
|
720
|
+
const parsed = parseJsonArgs(args);
|
|
721
|
+
if (parsed === null)
|
|
722
|
+
return args.slice(0, 120);
|
|
723
|
+
const preferred = [
|
|
724
|
+
'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
|
|
725
|
+
'description', 'content', 'file_text', 'old_string', 'new_string',
|
|
726
|
+
'old_str', 'new_str', 'insert_line', 'line', 'offset', 'limit',
|
|
727
|
+
];
|
|
728
|
+
const parts = [];
|
|
729
|
+
for (const key of preferred) {
|
|
730
|
+
const value = parsed[key];
|
|
731
|
+
if (value === undefined || value === null || typeof value === 'object')
|
|
732
|
+
continue;
|
|
733
|
+
parts.push(`${key}: ${String(value)}`);
|
|
734
|
+
if (parts.length >= 3)
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
if (parts.length === 0) {
|
|
738
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
739
|
+
const text = scalarText(value);
|
|
740
|
+
if (text !== null) {
|
|
741
|
+
parts.push(`${key}: ${text}`);
|
|
742
|
+
if (parts.length >= 3)
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const summary = parts.join(' ');
|
|
748
|
+
return summary === '' ? name : summary.slice(0, 160);
|
|
749
|
+
}
|
|
750
|
+
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
751
|
+
const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
752
|
+
/** Derive the intended file change from a mutation tool's arguments. */
|
|
753
|
+
function diffHunksFromArgs(name, argsRaw) {
|
|
754
|
+
const args = parseJsonArgs(argsRaw);
|
|
755
|
+
if (args === null)
|
|
756
|
+
return null;
|
|
757
|
+
if (name === 'edit' || name === 'write') {
|
|
758
|
+
const path = typeof args.file_path === 'string' ? args.file_path : '';
|
|
759
|
+
if (path === '')
|
|
760
|
+
return null;
|
|
761
|
+
if (name === 'edit') {
|
|
762
|
+
return [{
|
|
763
|
+
path,
|
|
764
|
+
oldText: typeof args.old_string === 'string' ? args.old_string : null,
|
|
765
|
+
newText: typeof args.new_string === 'string' ? args.new_string : '',
|
|
766
|
+
}];
|
|
767
|
+
}
|
|
768
|
+
return [{
|
|
769
|
+
path,
|
|
770
|
+
oldText: null,
|
|
771
|
+
newText: typeof args.content === 'string' ? args.content : '',
|
|
772
|
+
}];
|
|
773
|
+
}
|
|
774
|
+
if (name === 'str_replace_editor') {
|
|
775
|
+
const path = typeof args.path === 'string' ? args.path : '';
|
|
776
|
+
const command = typeof args.command === 'string' ? args.command : '';
|
|
777
|
+
if (path === '')
|
|
778
|
+
return null;
|
|
779
|
+
if (command === 'create') {
|
|
780
|
+
return [{ path, oldText: null, newText: typeof args.file_text === 'string' ? args.file_text : '' }];
|
|
781
|
+
}
|
|
782
|
+
if (command === 'str_replace') {
|
|
783
|
+
return [{
|
|
784
|
+
path,
|
|
785
|
+
oldText: typeof args.old_str === 'string' ? args.old_str : null,
|
|
786
|
+
newText: typeof args.new_str === 'string' ? args.new_str : '',
|
|
787
|
+
}];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
/** One-line friendly tool-call presentation (command / path / arg summary). */
|
|
793
|
+
export function presentToolCall(name, args) {
|
|
794
|
+
const parsed = parseJsonArgs(args);
|
|
795
|
+
if (SHELL_TOOL_NAMES.has(name)) {
|
|
796
|
+
const command = typeof parsed?.command === 'string' ? parsed.command : args.slice(0, 80);
|
|
797
|
+
return {
|
|
798
|
+
title: name,
|
|
799
|
+
summary: `$ ${command}`,
|
|
800
|
+
command,
|
|
801
|
+
cwd: typeof parsed?.workdir === 'string' ? parsed.workdir : undefined,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
if (DIFF_TOOL_NAMES.has(name)) {
|
|
805
|
+
const diff = diffHunksFromArgs(name, args);
|
|
806
|
+
const path = diff?.[0]?.path;
|
|
807
|
+
return {
|
|
808
|
+
title: name,
|
|
809
|
+
summary: path ?? friendlyArgsSummary(name, args),
|
|
810
|
+
...diff === null || diff === undefined ? {} : { diff },
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
return { title: name, summary: friendlyArgsSummary(name, args) };
|
|
814
|
+
}
|
|
815
|
+
/** Validate a tool/result meta payload's structured diff, mirroring the web card. */
|
|
816
|
+
export function diffMetaDiffs(meta) {
|
|
817
|
+
if (typeof meta !== 'object' || meta === null)
|
|
818
|
+
return null;
|
|
819
|
+
const diffs = meta.diffs;
|
|
820
|
+
if (!Array.isArray(diffs) || diffs.length === 0)
|
|
821
|
+
return null;
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const hunk of diffs) {
|
|
824
|
+
if (typeof hunk !== 'object' || hunk === null)
|
|
825
|
+
return null;
|
|
826
|
+
const { path, oldText, newText } = hunk;
|
|
827
|
+
if (typeof path !== 'string' || typeof newText !== 'string')
|
|
828
|
+
return null;
|
|
829
|
+
if (oldText !== null && typeof oldText !== 'string')
|
|
830
|
+
return null;
|
|
831
|
+
out.push({ path, oldText: oldText, newText });
|
|
832
|
+
}
|
|
833
|
+
return out;
|
|
834
|
+
}
|
|
835
|
+
/** Split one diff side into content lines (trailing newline is a terminator). */
|
|
836
|
+
function diffContentLines(text) {
|
|
837
|
+
if (text === '')
|
|
838
|
+
return [];
|
|
839
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
840
|
+
return body.split('\n');
|
|
841
|
+
}
|
|
842
|
+
/** Flatten hunks into git-style `-`/`+` lines plus the web-compatible footer. */
|
|
843
|
+
export function renderToolDiff(diffs, maxLines) {
|
|
844
|
+
const rows = [];
|
|
845
|
+
const paths = new Set();
|
|
846
|
+
let added = 0;
|
|
847
|
+
let removed = 0;
|
|
848
|
+
let prevPath;
|
|
849
|
+
for (const hunk of diffs) {
|
|
850
|
+
paths.add(hunk.path);
|
|
851
|
+
rows.push(hunk.path === prevPath
|
|
852
|
+
? { kind: 'diff-path', text: '⋯' }
|
|
853
|
+
: { kind: 'diff-path', text: hunk.path });
|
|
854
|
+
prevPath = hunk.path;
|
|
855
|
+
if (hunk.oldText !== null) {
|
|
856
|
+
for (const line of diffContentLines(hunk.oldText)) {
|
|
857
|
+
rows.push({ kind: 'diff-del', text: `- ${line}` });
|
|
858
|
+
removed += 1;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (const line of diffContentLines(hunk.newText)) {
|
|
862
|
+
rows.push({ kind: 'diff-add', text: `+ ${line}` });
|
|
863
|
+
added += 1;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
rows.push({
|
|
867
|
+
kind: 'tool-result',
|
|
868
|
+
text: `└ +${added} -${removed} · ${paths.size} file${paths.size === 1 ? '' : 's'}`,
|
|
869
|
+
});
|
|
870
|
+
if (rows.length <= maxLines)
|
|
871
|
+
return rows;
|
|
872
|
+
const head = rows.slice(0, Math.max(1, maxLines - 1));
|
|
873
|
+
const tail = rows.slice(-1);
|
|
874
|
+
return [
|
|
875
|
+
...head,
|
|
876
|
+
{ kind: 'tool-result', text: `… ${rows.length - head.length - 1} more line(s) …` },
|
|
877
|
+
...tail,
|
|
878
|
+
];
|
|
879
|
+
}
|
|
880
|
+
/** Keys whose multiline strings render as indented content blocks. */
|
|
881
|
+
const LONG_TEXT_KEYS = new Set([
|
|
882
|
+
'program', 'content', 'file_text', 'new_string', 'old_string',
|
|
883
|
+
'plan', 'markdown', 'details', 'description', 'text',
|
|
884
|
+
]);
|
|
885
|
+
const JSON_STRING_CAP = 400;
|
|
886
|
+
/** Convert any parsed JSON value into readable indented display lines. */
|
|
887
|
+
export function friendlyJsonLines(value, depth = 0) {
|
|
888
|
+
const pad = ' '.repeat(depth);
|
|
889
|
+
if (value === null)
|
|
890
|
+
return [`${pad}null`];
|
|
891
|
+
if (typeof value === 'string') {
|
|
892
|
+
const capped = value.length > JSON_STRING_CAP ? `${value.slice(0, JSON_STRING_CAP)}…` : value;
|
|
893
|
+
return [`${pad}${capped}`];
|
|
894
|
+
}
|
|
895
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
896
|
+
return [`${pad}${String(value)}`];
|
|
897
|
+
}
|
|
898
|
+
if (Array.isArray(value)) {
|
|
899
|
+
if (value.length === 0)
|
|
900
|
+
return [`${pad}[]`];
|
|
901
|
+
const lines = [];
|
|
902
|
+
for (const item of value) {
|
|
903
|
+
if (item !== null && typeof item === 'object') {
|
|
904
|
+
lines.push(`${pad}-`);
|
|
905
|
+
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
lines.push(`${pad}- ${friendlyJsonLines(item, 0)[0] ?? ''}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return lines;
|
|
912
|
+
}
|
|
913
|
+
if (typeof value === 'object') {
|
|
914
|
+
const entries = Object.entries(value);
|
|
915
|
+
if (entries.length === 0)
|
|
916
|
+
return [`${pad}{}`];
|
|
917
|
+
const lines = [];
|
|
918
|
+
for (const [key, item] of entries) {
|
|
919
|
+
if (typeof item === 'string' && item.includes('\n') && LONG_TEXT_KEYS.has(key)) {
|
|
920
|
+
const contentLines = item.split('\n');
|
|
921
|
+
lines.push(`${pad}${key}:`);
|
|
922
|
+
for (const contentLine of contentLines.slice(0, 80)) {
|
|
923
|
+
lines.push(`${pad} │ ${contentLine}`);
|
|
924
|
+
}
|
|
925
|
+
if (contentLines.length > 80) {
|
|
926
|
+
lines.push(`${pad} … ${contentLines.length - 80} more line(s)`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
else if (item !== null && typeof item === 'object') {
|
|
930
|
+
lines.push(`${pad}${key}:`);
|
|
931
|
+
lines.push(...friendlyJsonLines(item, depth + 1));
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
const scalar = friendlyJsonLines(item, 0)[0] ?? '';
|
|
935
|
+
lines.push(`${pad}${key}: ${scalar}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return lines;
|
|
939
|
+
}
|
|
940
|
+
return [`${pad}${String(value)}`];
|
|
941
|
+
}
|
|
942
|
+
/** Try to parse a result body as one JSON document, when it looks like one. */
|
|
943
|
+
function parseJsonBody(text) {
|
|
944
|
+
const trimmed = text.trim();
|
|
945
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
946
|
+
return null;
|
|
947
|
+
try {
|
|
948
|
+
return JSON.parse(trimmed);
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* The expanded body of one tool card: diffs and shell output keep their
|
|
956
|
+
* dedicated views; every other tool's JSON arguments and JSON result are
|
|
957
|
+
* converted into readable indented content instead of raw JSON text.
|
|
958
|
+
*/
|
|
959
|
+
export function toolBodyLines(row, maxLines) {
|
|
960
|
+
if (row.diff !== undefined && row.diff.length > 0) {
|
|
961
|
+
// File-edit diffs are never truncated: omitting hunks would hide the
|
|
962
|
+
// exact code change the model applied. `maxLines` only governs shell and
|
|
963
|
+
// generic JSON output bodies.
|
|
964
|
+
return renderToolDiff(row.diff, Number.MAX_SAFE_INTEGER);
|
|
965
|
+
}
|
|
966
|
+
if (row.command !== undefined) {
|
|
967
|
+
const out = [];
|
|
968
|
+
if (row.output !== '') {
|
|
969
|
+
for (const line of truncate(row.output, maxLines).split('\n')) {
|
|
970
|
+
out.push({ kind: row.status === 'error' ? 'error' : 'tool-result', text: line });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
else if (row.status !== 'running' && row.status !== undefined) {
|
|
974
|
+
out.push({ kind: 'tool-result', text: '(无输出)' });
|
|
975
|
+
}
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
const out = [];
|
|
979
|
+
const args = parseJsonArgs(row.args);
|
|
980
|
+
if (args !== null && Object.keys(args).length > 0) {
|
|
981
|
+
out.push({ kind: 'diff-path', text: '参数' });
|
|
982
|
+
for (const line of friendlyJsonLines(args)) {
|
|
983
|
+
out.push({ kind: 'tool-result', text: line });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (row.output !== '') {
|
|
987
|
+
out.push({ kind: 'diff-path', text: '结果' });
|
|
988
|
+
const parsed = parseJsonBody(row.output);
|
|
989
|
+
if (parsed !== null) {
|
|
990
|
+
for (const line of friendlyJsonLines(parsed)) {
|
|
991
|
+
out.push({ kind: 'tool-result', text: line });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
for (const line of truncate(row.output, maxLines).split('\n')) {
|
|
996
|
+
out.push({ kind: 'tool-result', text: line });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
/** Recover the shell tools' exit marker, mirroring @deepseek-ai/dsh-shell/render. */
|
|
1003
|
+
export function parseExitStatus(text) {
|
|
1004
|
+
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text);
|
|
1005
|
+
if (signal?.[1] !== undefined) {
|
|
1006
|
+
return { body: text.slice(0, signal.index), signal: signal[1] };
|
|
1007
|
+
}
|
|
1008
|
+
const exit = /\n\[exit code: (\d+)\]$/.exec(text);
|
|
1009
|
+
if (exit?.[1] !== undefined) {
|
|
1010
|
+
return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) };
|
|
1011
|
+
}
|
|
1012
|
+
return { body: text, exitCode: 0 };
|
|
1013
|
+
}
|
|
1014
|
+
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
1015
|
+
export function formatTokens(n) {
|
|
1016
|
+
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
1017
|
+
if (n < 1_000)
|
|
1018
|
+
return String(n);
|
|
1019
|
+
if (n < 1_000_000)
|
|
1020
|
+
return `${scaled(n / 1_000)}K`;
|
|
1021
|
+
return `${scaled(n / 1_000_000)}M`;
|
|
1022
|
+
}
|
|
1023
|
+
/** Compact duration, matching the web stats line (45.2s / 2m42s). */
|
|
1024
|
+
export function formatDuration(ms) {
|
|
1025
|
+
const seconds = ms / 1_000;
|
|
1026
|
+
if (seconds < 60)
|
|
1027
|
+
return `${Math.round(seconds * 10) / 10}s`;
|
|
1028
|
+
const whole = Math.round(seconds);
|
|
1029
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
1030
|
+
}
|
|
1031
|
+
export function formatTokensPerSecond(tokensPerSecond) {
|
|
1032
|
+
return `${Math.round(tokensPerSecond)} tok/s`;
|
|
1033
|
+
}
|
|
1034
|
+
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
1035
|
+
export class SshTui {
|
|
1036
|
+
ctx;
|
|
1037
|
+
agent;
|
|
1038
|
+
rows = [];
|
|
1039
|
+
streaming;
|
|
1040
|
+
input = '';
|
|
1041
|
+
cursor = 0;
|
|
1042
|
+
inputFolded = false;
|
|
1043
|
+
history = [];
|
|
1044
|
+
historyIndex = -1;
|
|
1045
|
+
status = 'idle';
|
|
1046
|
+
dialog;
|
|
1047
|
+
dirty = true;
|
|
1048
|
+
disposed = false;
|
|
1049
|
+
exiting = false;
|
|
1050
|
+
renderTimer;
|
|
1051
|
+
decoder = new StringDecoder('utf8');
|
|
1052
|
+
color;
|
|
1053
|
+
maxToolOutputLines;
|
|
1054
|
+
showReasoning;
|
|
1055
|
+
goodbye;
|
|
1056
|
+
resume;
|
|
1057
|
+
providerName;
|
|
1058
|
+
selectionRef;
|
|
1059
|
+
onSwitchSession;
|
|
1060
|
+
onSelectionChanged;
|
|
1061
|
+
resumePicker;
|
|
1062
|
+
disposers = [];
|
|
1063
|
+
userQuestionDisposer;
|
|
1064
|
+
presetId = 'standard';
|
|
1065
|
+
presetName = '标准模式';
|
|
1066
|
+
useAlternateScreen;
|
|
1067
|
+
agentGone = false;
|
|
1068
|
+
onboarding;
|
|
1069
|
+
commandSuggestions = [];
|
|
1070
|
+
suggestionIndex = 0;
|
|
1071
|
+
focusedRow = null;
|
|
1072
|
+
pendingMessages = new Map();
|
|
1073
|
+
lastActivity = Date.now();
|
|
1074
|
+
stalledWarningShown = false;
|
|
1075
|
+
lastPaintAt = 0;
|
|
1076
|
+
activeSubagents = new Map();
|
|
1077
|
+
subagentSessions = new Set();
|
|
1078
|
+
openToolCalls = new Map();
|
|
1079
|
+
stats = {
|
|
1080
|
+
turns: 0,
|
|
1081
|
+
steps: 0,
|
|
1082
|
+
llmMs: 0,
|
|
1083
|
+
toolMs: 0,
|
|
1084
|
+
ttftMs: 0,
|
|
1085
|
+
ttftSteps: 0,
|
|
1086
|
+
decodeMs: 0,
|
|
1087
|
+
decodeTokens: 0,
|
|
1088
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
1089
|
+
};
|
|
1090
|
+
openStepStats;
|
|
1091
|
+
pendingToolTimes = new Map();
|
|
1092
|
+
usageByStep = new Map();
|
|
1093
|
+
lastStatsTurn = null;
|
|
1094
|
+
scrollOffset = 0;
|
|
1095
|
+
clickableRows = new Map();
|
|
1096
|
+
streamingReasoning;
|
|
1097
|
+
escapeBuffer = '';
|
|
1098
|
+
escapeTimer;
|
|
1099
|
+
thinkingStartedAt;
|
|
1100
|
+
completionSignaled = false;
|
|
1101
|
+
completedAt = 0;
|
|
1102
|
+
lastTitleUpdateAt = 0;
|
|
1103
|
+
lastPaintRows = [];
|
|
1104
|
+
lastChromeKey = '';
|
|
1105
|
+
constructor(ctx, agent, config) {
|
|
1106
|
+
this.ctx = ctx;
|
|
1107
|
+
this.agent = agent;
|
|
1108
|
+
const noColorEnv = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
|
|
1109
|
+
this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
|
|
1110
|
+
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
1111
|
+
this.showReasoning = config.showReasoning !== false;
|
|
1112
|
+
this.goodbye = this.ctx.get('tuiGoodbyeMessage')
|
|
1113
|
+
?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
|
|
1114
|
+
this.resume = config.resume === true;
|
|
1115
|
+
this.providerName = config.provider ?? 'deepseek-official';
|
|
1116
|
+
this.selectionRef = config.selectionRef;
|
|
1117
|
+
this.onSwitchSession = config.onSwitchSession;
|
|
1118
|
+
this.onSelectionChanged = config.onSelectionChanged;
|
|
1119
|
+
this.resumePicker = config.resumePicker === true;
|
|
1120
|
+
this.presetId = config.presetId ?? 'standard';
|
|
1121
|
+
this.presetName = config.presetName ?? this.presetId;
|
|
1122
|
+
this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
|
|
1123
|
+
this.pushRow({ kind: 'brand-logo' });
|
|
1124
|
+
this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
|
|
1125
|
+
this.pushRow({ kind: 'system', text: 'Type /help for commands · /setup provider & key · ↑/↓ select · Enter expand/collapse · Ctrl+T fold input · Esc cancels' });
|
|
1126
|
+
}
|
|
1127
|
+
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
1128
|
+
start() {
|
|
1129
|
+
process.stdin.setRawMode(true);
|
|
1130
|
+
process.stdin.resume();
|
|
1131
|
+
process.stdin.on('data', this.handleData);
|
|
1132
|
+
process.stdout.on('resize', this.markDirty);
|
|
1133
|
+
process.on('SIGWINCH', this.markDirty);
|
|
1134
|
+
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));
|
|
1135
|
+
const questions = this.ctx.get('userQuestions');
|
|
1136
|
+
if (questions !== undefined) {
|
|
1137
|
+
this.userQuestionDisposer = questions.registerProvider({ ask: this.handleUserQuestions });
|
|
1138
|
+
}
|
|
1139
|
+
this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?25l`);
|
|
1140
|
+
this.render();
|
|
1141
|
+
this.updateTerminalTitle();
|
|
1142
|
+
if (this.resumePicker) {
|
|
1143
|
+
void this.runResumeCommand('', true);
|
|
1144
|
+
}
|
|
1145
|
+
this.renderTimer = setInterval(() => {
|
|
1146
|
+
const now = Date.now();
|
|
1147
|
+
if (this.agent.status === 'running')
|
|
1148
|
+
this.updateTerminalTitle();
|
|
1149
|
+
if (this.streaming !== undefined
|
|
1150
|
+
&& this.streaming.reasoning !== ''
|
|
1151
|
+
&& now - this.lastPaintAt >= 200) {
|
|
1152
|
+
this.dirty = true;
|
|
1153
|
+
}
|
|
1154
|
+
// While a turn is waiting on the provider with no new events, repaint at
|
|
1155
|
+
// most once per second so slow SSH links do not drown in redraws.
|
|
1156
|
+
const idleWaiting = this.agent.status === 'running' && !this.dirty;
|
|
1157
|
+
if (idleWaiting && now - this.lastPaintAt < 1000)
|
|
1158
|
+
return;
|
|
1159
|
+
// Running with no fresh events only needs the seconds-bearing status
|
|
1160
|
+
// line refreshed; force one repaint per second instead of every tick.
|
|
1161
|
+
if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
|
|
1162
|
+
this.dirty = true;
|
|
1163
|
+
}
|
|
1164
|
+
if (this.dirty) {
|
|
1165
|
+
this.lastPaintAt = now;
|
|
1166
|
+
this.render();
|
|
1167
|
+
}
|
|
1168
|
+
}, RENDER_INTERVAL_MS);
|
|
1169
|
+
this.renderTimer.unref?.();
|
|
1170
|
+
void this.maybeRunOnboarding();
|
|
1171
|
+
}
|
|
1172
|
+
/** Replay the durable session log so a resumed session renders its history. */
|
|
1173
|
+
replayHistory() {
|
|
1174
|
+
for (const event of this.agent.session.events) {
|
|
1175
|
+
this.handleSessionEvent(this.agent.session, event);
|
|
1176
|
+
}
|
|
1177
|
+
this.streaming = undefined;
|
|
1178
|
+
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
1179
|
+
this.dirty = true;
|
|
1180
|
+
}
|
|
1181
|
+
/** Show the first-launch provider/API-key onboarding when nothing is configured. */
|
|
1182
|
+
async maybeRunOnboarding() {
|
|
1183
|
+
const credentials = this.ctx.get('credentials');
|
|
1184
|
+
const provider = this.providerName;
|
|
1185
|
+
const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
|
|
1186
|
+
const envKey = process.env[envRef];
|
|
1187
|
+
let stored = false;
|
|
1188
|
+
if (credentials !== undefined) {
|
|
1189
|
+
stored = (await credentials.describe(credentialRef(envRef))).configured;
|
|
1190
|
+
}
|
|
1191
|
+
if (!stored) {
|
|
1192
|
+
// Belt-and-braces: the file provider may not have its in-memory snapshot
|
|
1193
|
+
// visible to this plugin copy yet; the managed document is authoritative.
|
|
1194
|
+
try {
|
|
1195
|
+
const credentialFile = join(dshHomeDir(), '.credentials.yaml');
|
|
1196
|
+
if (existsSync(credentialFile)) {
|
|
1197
|
+
const content = await readFile(credentialFile, 'utf8');
|
|
1198
|
+
stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
// Ignore unreadable/missing documents; the wizard will ask again.
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (envKey !== undefined && envKey !== '') {
|
|
1206
|
+
if (stored || existsSync(DSH_ENV_FILE) || this.resume) {
|
|
1207
|
+
this.pushRow({
|
|
1208
|
+
kind: 'system',
|
|
1209
|
+
text: `当前使用 ${envRef}(环境变量/启动环境文件)。如需更换,随时输入 /setup 重新配置。`,
|
|
1210
|
+
});
|
|
1211
|
+
this.markDirty();
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
this.pushRow({
|
|
1215
|
+
kind: 'system',
|
|
1216
|
+
text: `检测到系统已注入 ${envRef}(可能已失效)。首次启动向导将覆盖为你的 Key,保存后重启 TUI 生效。`,
|
|
1217
|
+
});
|
|
1218
|
+
await this.runOnboarding();
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (stored || this.resume)
|
|
1222
|
+
return;
|
|
1223
|
+
this.pushRow({ kind: 'system', text: '首次启动:请先配置提供商和 API Key(随时可输入 /setup 重新配置)。' });
|
|
1224
|
+
await this.runOnboarding();
|
|
1225
|
+
}
|
|
1226
|
+
/** Run the provider/API-key onboarding wizard. Resolves true when saved. */
|
|
1227
|
+
runOnboarding() {
|
|
1228
|
+
return new Promise((resolve) => {
|
|
1229
|
+
this.onboarding = {
|
|
1230
|
+
step: 'provider',
|
|
1231
|
+
providerType: 'official',
|
|
1232
|
+
providerId: '',
|
|
1233
|
+
baseUrl: '',
|
|
1234
|
+
key: '',
|
|
1235
|
+
models: [],
|
|
1236
|
+
resolve,
|
|
1237
|
+
};
|
|
1238
|
+
this.input = '';
|
|
1239
|
+
this.cursor = 0;
|
|
1240
|
+
this.dialog = { kind: 'onboarding' };
|
|
1241
|
+
this.markDirty();
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
cancelOnboarding() {
|
|
1245
|
+
const state = this.onboarding;
|
|
1246
|
+
if (state === undefined)
|
|
1247
|
+
return;
|
|
1248
|
+
this.onboarding = undefined;
|
|
1249
|
+
this.dialog = undefined;
|
|
1250
|
+
this.input = '';
|
|
1251
|
+
this.cursor = 0;
|
|
1252
|
+
state.resolve(false);
|
|
1253
|
+
this.markDirty();
|
|
1254
|
+
}
|
|
1255
|
+
/** Restore the terminal, flush the session, and request process exit. */
|
|
1256
|
+
async dispose() {
|
|
1257
|
+
if (this.disposed)
|
|
1258
|
+
return;
|
|
1259
|
+
this.disposed = true;
|
|
1260
|
+
this.exiting = true;
|
|
1261
|
+
const dialog = this.dialog;
|
|
1262
|
+
if (dialog !== undefined) {
|
|
1263
|
+
if (dialog.kind === 'confirm') {
|
|
1264
|
+
dialog.resolve('cancel');
|
|
1265
|
+
}
|
|
1266
|
+
else if (dialog.kind === 'questions') {
|
|
1267
|
+
dialog.reject(new UserQuestionError('TUI closed before the question was answered', 'ASK_ABORTED'));
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
this.cancelOnboarding();
|
|
1271
|
+
}
|
|
1272
|
+
this.dialog = undefined;
|
|
1273
|
+
}
|
|
1274
|
+
if (this.renderTimer !== undefined)
|
|
1275
|
+
clearInterval(this.renderTimer);
|
|
1276
|
+
for (const dispose of this.disposers.splice(0)) {
|
|
1277
|
+
dispose();
|
|
1278
|
+
}
|
|
1279
|
+
this.userQuestionDisposer?.();
|
|
1280
|
+
this.userQuestionDisposer = undefined;
|
|
1281
|
+
process.stdin.removeListener('data', this.handleData);
|
|
1282
|
+
process.stdout.removeListener('resize', this.markDirty);
|
|
1283
|
+
process.removeListener('SIGWINCH', this.markDirty);
|
|
1284
|
+
process.stdin.setRawMode(false);
|
|
1285
|
+
process.stdin.pause();
|
|
1286
|
+
this.write('\x1b]0;\x07');
|
|
1287
|
+
// Clear every screen (regular + scrollback) before restoring the terminal.
|
|
1288
|
+
// In no-alternate-screen mode this removes the last painted frame that
|
|
1289
|
+
// would otherwise stay behind the shell prompt after exit.
|
|
1290
|
+
this.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
1291
|
+
this.write(`\x1b[?1000l\x1b[?1006l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
|
|
1292
|
+
}
|
|
1293
|
+
/** Human-facing exit with goodbye and flush; called from key handling. */
|
|
1294
|
+
async requestExit(code) {
|
|
1295
|
+
if (this.exiting)
|
|
1296
|
+
return;
|
|
1297
|
+
this.exiting = true;
|
|
1298
|
+
await this.dispose();
|
|
1299
|
+
process.stdout.write(`\n${this.goodbye}\n`);
|
|
1300
|
+
try {
|
|
1301
|
+
await this.ctx.get('sessions')?.flush(this.agent.session);
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
process.stdout.write(`dsh-ssh-tui: failed to flush session: ${errorChain(error)}\n`);
|
|
1305
|
+
}
|
|
1306
|
+
const exit = this.ctx.get('appExit');
|
|
1307
|
+
if (exit !== undefined)
|
|
1308
|
+
exit(code);
|
|
1309
|
+
else
|
|
1310
|
+
process.exit(code);
|
|
1311
|
+
}
|
|
1312
|
+
// ── terminal output ─────────────────────────────────────────────────────
|
|
1313
|
+
write(chunk) {
|
|
1314
|
+
process.stdout.write(chunk);
|
|
1315
|
+
}
|
|
1316
|
+
markDirty = () => {
|
|
1317
|
+
this.dirty = true;
|
|
1318
|
+
};
|
|
1319
|
+
/** Append one transcript row, bounding memory on long sessions. */
|
|
1320
|
+
pushRow(row) {
|
|
1321
|
+
this.rows.push(row);
|
|
1322
|
+
if (this.rows.length > MAX_TRANSCRIPT_ROWS) {
|
|
1323
|
+
const removed = this.rows.length - MAX_TRANSCRIPT_ROWS;
|
|
1324
|
+
if (this.focusedRow !== null
|
|
1325
|
+
&& this.focusedRow.kind !== 'streaming-reasoning'
|
|
1326
|
+
&& this.rows.indexOf(this.focusedRow) < removed) {
|
|
1327
|
+
this.focusedRow = null;
|
|
1328
|
+
}
|
|
1329
|
+
this.rows.splice(0, removed);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/** The transcript rows that support per-row expand/collapse. */
|
|
1333
|
+
collapsibleRows() {
|
|
1334
|
+
const rows = this.rows.filter((row) => row.kind === 'reasoning' || row.kind === 'tool');
|
|
1335
|
+
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
1336
|
+
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
1337
|
+
rows.push(this.streamingReasoning);
|
|
1338
|
+
}
|
|
1339
|
+
return rows;
|
|
1340
|
+
}
|
|
1341
|
+
/** Move the expand/collapse focus among reasoning and tool rows. */
|
|
1342
|
+
moveCollapsibleFocus(delta) {
|
|
1343
|
+
const rows = this.collapsibleRows();
|
|
1344
|
+
if (rows.length === 0)
|
|
1345
|
+
return;
|
|
1346
|
+
if (this.focusedRow === null) {
|
|
1347
|
+
const target = delta >= 0 ? rows[0] : rows[rows.length - 1];
|
|
1348
|
+
if (target !== undefined)
|
|
1349
|
+
this.focusedRow = target;
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
const current = rows.indexOf(this.focusedRow);
|
|
1353
|
+
const next = rows[current === -1 ? (delta >= 0 ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, current + delta))];
|
|
1354
|
+
if (next !== undefined)
|
|
1355
|
+
this.focusedRow = next;
|
|
1356
|
+
}
|
|
1357
|
+
this.markDirty();
|
|
1358
|
+
}
|
|
1359
|
+
/** Toggle the focused block; without focus, toggle the most recent one. */
|
|
1360
|
+
toggleCollapsible() {
|
|
1361
|
+
const rows = this.collapsibleRows();
|
|
1362
|
+
if (rows.length === 0)
|
|
1363
|
+
return;
|
|
1364
|
+
const focused = this.focusedRow !== null && rows.includes(this.focusedRow)
|
|
1365
|
+
? this.focusedRow
|
|
1366
|
+
: undefined;
|
|
1367
|
+
const target = focused ?? rows[rows.length - 1];
|
|
1368
|
+
if (target === undefined)
|
|
1369
|
+
return;
|
|
1370
|
+
target.expanded = !target.expanded;
|
|
1371
|
+
this.focusedRow = target;
|
|
1372
|
+
this.markDirty();
|
|
1373
|
+
}
|
|
1374
|
+
/** Expand all collapsible blocks, or collapse them again when all are open. */
|
|
1375
|
+
toggleAllCollapsible() {
|
|
1376
|
+
const rows = this.collapsibleRows();
|
|
1377
|
+
if (rows.length === 0)
|
|
1378
|
+
return;
|
|
1379
|
+
const allExpanded = rows.every(row => row.expanded);
|
|
1380
|
+
for (const row of rows)
|
|
1381
|
+
row.expanded = !allExpanded;
|
|
1382
|
+
this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
|
|
1383
|
+
this.markDirty();
|
|
1384
|
+
}
|
|
1385
|
+
paint = () => {
|
|
1386
|
+
if (this.exiting)
|
|
1387
|
+
return;
|
|
1388
|
+
const width = Math.max(10, process.stdout.columns || 80);
|
|
1389
|
+
const height = Math.max(6, process.stdout.rows || 24);
|
|
1390
|
+
const display = [];
|
|
1391
|
+
const displayRefs = [];
|
|
1392
|
+
const addDisplay = (line, ref) => {
|
|
1393
|
+
display.push(line);
|
|
1394
|
+
displayRefs.push(ref);
|
|
1395
|
+
};
|
|
1396
|
+
const pushRow = (kind, text) => {
|
|
1397
|
+
if (kind === 'assistant') {
|
|
1398
|
+
for (const line of renderMarkdownLines(text, width, this.color)) {
|
|
1399
|
+
addDisplay(line);
|
|
1400
|
+
}
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
for (const line of wrap(text, width)) {
|
|
1404
|
+
addDisplay(this.styleLine(kind, line));
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
for (const row of this.rows) {
|
|
1408
|
+
if (row.kind === 'brand-logo') {
|
|
1409
|
+
const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
|
|
1410
|
+
?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
|
|
1411
|
+
for (const line of variant.lines) {
|
|
1412
|
+
const pad = Math.max(0, Math.floor((width - displayWidth(line)) / 2));
|
|
1413
|
+
addDisplay(this.styleLine('brand', ' '.repeat(pad) + line));
|
|
1414
|
+
}
|
|
1415
|
+
const wordmark = 'DeepSeek';
|
|
1416
|
+
const wordmarkPad = Math.max(0, Math.floor((width - displayWidth(wordmark)) / 2));
|
|
1417
|
+
addDisplay(this.styleLine('brand', ' '.repeat(wordmarkPad) + wordmark));
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (row.kind === 'reasoning') {
|
|
1421
|
+
const focused = this.focusedRow === row;
|
|
1422
|
+
const marker = row.expanded ? '▾' : '▸';
|
|
1423
|
+
const lines = row.text.split('\n').length;
|
|
1424
|
+
const header = `${marker} 已思考 · ${lines} 行${row.expanded ? '' : ' · Ctrl+R 展开'}`;
|
|
1425
|
+
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
1426
|
+
const styled = this.styleLine('reasoning', line);
|
|
1427
|
+
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
1428
|
+
if (row.expanded) {
|
|
1429
|
+
for (const wrapped of wrap(row.text, width)) {
|
|
1430
|
+
addDisplay(this.styleLine('reasoning', wrapped));
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (row.kind === 'tool') {
|
|
1436
|
+
const running = row.status === undefined || row.status === 'running';
|
|
1437
|
+
const ok = row.status === 'ok';
|
|
1438
|
+
const dot = this.color
|
|
1439
|
+
? running ? '\x1b[33m●\x1b[0m' : ok ? '\x1b[32m●\x1b[0m' : '\x1b[31m●\x1b[0m'
|
|
1440
|
+
: '●';
|
|
1441
|
+
const state = running ? 'running…' : ok ? 'ok' : 'error';
|
|
1442
|
+
const summary = row.summary === '' ? '' : ` ${row.summary}`;
|
|
1443
|
+
const exit = !running && row.command !== undefined
|
|
1444
|
+
? row.signal !== undefined
|
|
1445
|
+
? ` [信号 ${row.signal}]`
|
|
1446
|
+
: (row.exitCode ?? 0) !== 0
|
|
1447
|
+
? ` [退出码 ${row.exitCode}]`
|
|
1448
|
+
: ''
|
|
1449
|
+
: '';
|
|
1450
|
+
const focused = this.focusedRow === row;
|
|
1451
|
+
const marker = row.expanded ? '▾' : '▸';
|
|
1452
|
+
const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
|
|
1453
|
+
if (!row.expanded) {
|
|
1454
|
+
const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
|
|
1455
|
+
const dotIndex = collapsed.indexOf('●');
|
|
1456
|
+
const withDot = dotIndex === -1
|
|
1457
|
+
? collapsed
|
|
1458
|
+
: `${collapsed.slice(0, dotIndex)}${dot}${collapsed.slice(dotIndex + 1)}`;
|
|
1459
|
+
const styled = this.styleLine('tool', withDot);
|
|
1460
|
+
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
for (const [index, wrapped] of wrap(plainHeader, width).entries()) {
|
|
1464
|
+
const dotIndex = index === 0 ? wrapped.indexOf('●') : -1;
|
|
1465
|
+
const withDot = dotIndex === -1
|
|
1466
|
+
? wrapped
|
|
1467
|
+
: `${wrapped.slice(0, dotIndex)}${dot}${wrapped.slice(dotIndex + 1)}`;
|
|
1468
|
+
addDisplay(this.styleLine('tool', withDot), row);
|
|
1469
|
+
}
|
|
1470
|
+
for (const line of toolBodyLines(row, this.maxToolOutputLines)) {
|
|
1471
|
+
for (const wrapped of wrap(line.text, Math.max(1, width - 2))) {
|
|
1472
|
+
addDisplay(this.styleLine(line.kind, ` ${wrapped}`));
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
pushRow(row.kind, row.text);
|
|
1478
|
+
}
|
|
1479
|
+
if (this.streaming !== undefined) {
|
|
1480
|
+
if (this.showReasoning && this.streaming.reasoning !== '') {
|
|
1481
|
+
const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
1482
|
+
const focused = this.focusedRow === block;
|
|
1483
|
+
const marker = block.expanded ? '▾' : '▸';
|
|
1484
|
+
const spinner = SPINNER[Math.floor(Date.now() / 120) % SPINNER.length];
|
|
1485
|
+
const chars = this.streaming.reasoning.length;
|
|
1486
|
+
const elapsed = this.thinkingStartedAt === undefined
|
|
1487
|
+
? 0
|
|
1488
|
+
: Math.floor((Date.now() - this.thinkingStartedAt) / 1000);
|
|
1489
|
+
const header = `${marker} 思考中 ${spinner} · ${chars} 字${elapsed > 0 ? ` · ${elapsed}s` : ''}`;
|
|
1490
|
+
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
1491
|
+
const styled = this.styleLine('reasoning', line);
|
|
1492
|
+
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
|
|
1493
|
+
if (block.expanded) {
|
|
1494
|
+
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
1495
|
+
addDisplay(this.styleLine('reasoning', wrapped));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
if (this.streaming.text !== '') {
|
|
1500
|
+
// Streaming text is the model's live token stream: while reasoning is
|
|
1501
|
+
// being produced (before a final assistant message has assembled) it
|
|
1502
|
+
// can contain the raw thinking/chain-of-thought. Rendering it as
|
|
1503
|
+
// markdown here would style that thinking instead of keeping it in the
|
|
1504
|
+
// collapsible reasoning block, so keep the in-progress stream plain.
|
|
1505
|
+
// The completed assistant message is what gets markdown-rendered.
|
|
1506
|
+
for (const line of wrap(this.streaming.text, width)) {
|
|
1507
|
+
addDisplay(this.styleLine('assistant', line));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const dialogLines = [];
|
|
1512
|
+
const addDialog = (text) => {
|
|
1513
|
+
for (const wrapped of wrap(text, Math.max(1, width))) {
|
|
1514
|
+
dialogLines.push(this.styleLine('system', wrapped));
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
if (this.dialog !== undefined) {
|
|
1518
|
+
if (this.dialog.kind === 'confirm') {
|
|
1519
|
+
addDialog(this.dialog.prompt);
|
|
1520
|
+
addDialog(` ${this.dialog.hint}`);
|
|
1521
|
+
}
|
|
1522
|
+
else if (this.dialog.kind === 'onboarding') {
|
|
1523
|
+
const ob = this.onboarding;
|
|
1524
|
+
if (ob !== undefined) {
|
|
1525
|
+
const template = PROVIDER_TEMPLATES[ob.providerType];
|
|
1526
|
+
const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
|
|
1527
|
+
switch (ob.step) {
|
|
1528
|
+
case 'provider':
|
|
1529
|
+
addDialog('首次配置向导 — 选择提供商(与官方 Models 页一致)');
|
|
1530
|
+
addDialog(' 1 DeepSeek 官方(api.deepseek.com)');
|
|
1531
|
+
addDialog(' 2 OpenCode Go(opencode.ai/zen/go,Responses 协议)');
|
|
1532
|
+
addDialog(' 3 自定义 OpenAI 兼容网关(Completions)');
|
|
1533
|
+
addDialog(' 4 自定义 OpenAI Responses 网关');
|
|
1534
|
+
addDialog(' 5 Anthropic Messages 兼容网关');
|
|
1535
|
+
addDialog(' 按 1-5 选择,Esc 取消');
|
|
1536
|
+
break;
|
|
1537
|
+
case 'id':
|
|
1538
|
+
addDialog(`提供商:${providerLabel}`);
|
|
1539
|
+
addDialog('Provider ID(小写字母/数字/连字符,永久标识):');
|
|
1540
|
+
addDialog(` 默认:${template.defaultId}`);
|
|
1541
|
+
addDialog(' Enter 确认,Esc 取消');
|
|
1542
|
+
break;
|
|
1543
|
+
case 'key':
|
|
1544
|
+
addDialog(`提供商:${providerLabel}`);
|
|
1545
|
+
addDialog('请输入 API Key(输入时以 • 显示):');
|
|
1546
|
+
addDialog(' Enter 确认,Esc 取消');
|
|
1547
|
+
break;
|
|
1548
|
+
case 'base-url':
|
|
1549
|
+
addDialog(`提供商:${providerLabel}`);
|
|
1550
|
+
addDialog(`请输入 Base URL(留空使用 ${template.defaultBaseUrl || '官方/模板默认'}):`);
|
|
1551
|
+
addDialog(' Enter 确认,Esc 取消');
|
|
1552
|
+
break;
|
|
1553
|
+
case 'models':
|
|
1554
|
+
addDialog(`提供商:${providerLabel}`);
|
|
1555
|
+
addDialog('模型 ID(多个用逗号或空格分隔):');
|
|
1556
|
+
addDialog(` 默认:${template.defaultModels.join(', ')}`);
|
|
1557
|
+
if (template.api !== undefined)
|
|
1558
|
+
addDialog(' Ctrl+F = 从端点获取模型列表');
|
|
1559
|
+
addDialog(' Enter 确认,Esc 取消');
|
|
1560
|
+
break;
|
|
1561
|
+
case 'confirm':
|
|
1562
|
+
addDialog('确认保存以下配置?');
|
|
1563
|
+
addDialog(` 提供商: ${providerLabel}`);
|
|
1564
|
+
addDialog(` Provider ID: ${ob.providerId}`);
|
|
1565
|
+
addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
|
|
1566
|
+
addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
|
|
1567
|
+
addDialog(` 模型: ${ob.models.join(', ')}`);
|
|
1568
|
+
addDialog(` API Key: ${ob.key.slice(0, 6)}…${ob.key.slice(-4)}(长度 ${ob.key.length})`);
|
|
1569
|
+
addDialog(' y = 保存, n = 重填, Esc = 取消');
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
const d = this.dialog;
|
|
1576
|
+
addDialog(`Question ${d.index + 1}/${d.total}: ${d.question.question}`);
|
|
1577
|
+
if (d.question.detail !== undefined && d.question.detail !== '') {
|
|
1578
|
+
addDialog(truncate(d.question.detail, 6));
|
|
1579
|
+
}
|
|
1580
|
+
const options = d.question.options ?? [];
|
|
1581
|
+
for (const [index, option] of options.entries()) {
|
|
1582
|
+
const marker = d.selected.has(index) ? '●' : '○';
|
|
1583
|
+
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
1584
|
+
addDialog(` ${index + 1} ${marker} ${option.label}${extra}`);
|
|
1585
|
+
}
|
|
1586
|
+
if (options.length === 0) {
|
|
1587
|
+
addDialog(' (free text: type below and press Enter)');
|
|
1588
|
+
}
|
|
1589
|
+
addDialog(` ${d.question.multiSelect === true ? 'digits toggle, Enter submit' : 'digit to select, Enter submit'}, Esc to cancel`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
1593
|
+
const headerLines = [
|
|
1594
|
+
this.styleLine('system', fitLine(`DeepSeek Harness — SSH TUI [${this.presetName}] ${this.currentSelectionLabel()}`)),
|
|
1595
|
+
this.styleLine('system', '─'.repeat(width)),
|
|
1596
|
+
];
|
|
1597
|
+
if (this.scrollOffset > 0) {
|
|
1598
|
+
headerLines.push(this.styleLine('system', fitLine(`↑ 已回看 ${this.scrollOffset} 行 · PgUp/PgDn/滚轮滚动 · Esc 回到底部`)));
|
|
1599
|
+
}
|
|
1600
|
+
const inputDivider = this.styleLine('system', '─'.repeat(width));
|
|
1601
|
+
this.commandSuggestions = this.dialog === undefined ? this.buildSuggestions() : [];
|
|
1602
|
+
if (this.suggestionIndex >= this.commandSuggestions.length) {
|
|
1603
|
+
this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
|
|
1604
|
+
}
|
|
1605
|
+
const suggestionLines = [];
|
|
1606
|
+
for (const [index, command] of this.commandSuggestions.entries()) {
|
|
1607
|
+
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
1608
|
+
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
1609
|
+
suggestionLines.push(index === this.suggestionIndex
|
|
1610
|
+
? `\x1b[7m${fitLine(line)}\x1b[27m`
|
|
1611
|
+
: this.styleLine('system', fitLine(line)));
|
|
1612
|
+
}
|
|
1613
|
+
const promptPlain = this.color ? '❯ ' : '> ';
|
|
1614
|
+
const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
|
|
1615
|
+
const promptWidth = displayWidth(promptPlain);
|
|
1616
|
+
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
1617
|
+
const inputView = masked
|
|
1618
|
+
? { text: '•'.repeat(this.input.length), cursorOffset: this.cursor, folded: false }
|
|
1619
|
+
: this.inputFolded
|
|
1620
|
+
? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
|
|
1621
|
+
: { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
|
|
1622
|
+
const grid = Math.max(1, width);
|
|
1623
|
+
const cursorPlainOffset = promptWidth + inputView.cursorOffset;
|
|
1624
|
+
const inputTextWidth = Math.max(1, width - promptWidth);
|
|
1625
|
+
const inputTextLines = wrap(inputView.text, inputTextWidth);
|
|
1626
|
+
const inputDisplayLines = inputTextLines.map((line, index) => index === 0 ? `${prompt}${line}` : line);
|
|
1627
|
+
// When the cursor sits exactly at the end of a full visual row, the
|
|
1628
|
+
// terminal has already advanced to the next row. Reserve that empty row so
|
|
1629
|
+
// the cursor never lands on top of the last character typed.
|
|
1630
|
+
if (!inputView.folded
|
|
1631
|
+
&& cursorPlainOffset > 0
|
|
1632
|
+
&& cursorPlainOffset % grid === 0
|
|
1633
|
+
&& Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
|
|
1634
|
+
inputDisplayLines.push('');
|
|
1635
|
+
}
|
|
1636
|
+
const inputRows = Math.max(1, inputDisplayLines.length);
|
|
1637
|
+
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + 1; // +1 input divider
|
|
1638
|
+
const available = Math.max(1, height - reserved - dialogLines.length);
|
|
1639
|
+
const maxOffset = Math.max(0, display.length - available);
|
|
1640
|
+
if (this.scrollOffset > maxOffset)
|
|
1641
|
+
this.scrollOffset = maxOffset;
|
|
1642
|
+
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
1643
|
+
const visible = display.slice(start, start + available);
|
|
1644
|
+
const visibleRefs = displayRefs.slice(start, start + available);
|
|
1645
|
+
const padding = Math.max(0, available - visible.length);
|
|
1646
|
+
for (let index = 0; index < padding; index++) {
|
|
1647
|
+
visible.unshift('');
|
|
1648
|
+
visibleRefs.unshift(undefined);
|
|
1649
|
+
}
|
|
1650
|
+
this.clickableRows.clear();
|
|
1651
|
+
for (let index = 0; index < visibleRefs.length; index++) {
|
|
1652
|
+
const ref = visibleRefs[index];
|
|
1653
|
+
if (ref !== undefined)
|
|
1654
|
+
this.clickableRows.set(headerLines.length + index + 1, ref);
|
|
1655
|
+
}
|
|
1656
|
+
const statsText = this.statsText();
|
|
1657
|
+
const statsLine = this.styleLine('system', fitLine(statsText === '' ? '— 尚无会话统计' : statsText));
|
|
1658
|
+
let statusText = `${this.status} [${this.presetName}] ${this.currentSelectionLabel()}`;
|
|
1659
|
+
if (inputView.folded)
|
|
1660
|
+
statusText += ' · 输入已折叠 · Ctrl+T 展开';
|
|
1661
|
+
else if (inputRows > 1)
|
|
1662
|
+
statusText += ' · Ctrl+T 折叠输入';
|
|
1663
|
+
if (this.pendingMessages.size > 0)
|
|
1664
|
+
statusText += ` · 排队 ${this.pendingMessages.size}`;
|
|
1665
|
+
const idleMs = Date.now() - this.lastActivity;
|
|
1666
|
+
if (this.agent.status === 'running' && this.activeSubagents.size > 0) {
|
|
1667
|
+
statusText += ` · 子代理执行中 ${this.activeSubagents.size}`;
|
|
1668
|
+
}
|
|
1669
|
+
else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
|
|
1670
|
+
statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
|
|
1671
|
+
}
|
|
1672
|
+
else if (this.agent.status === 'running' && idleMs > WAIT_INDICATOR_MS) {
|
|
1673
|
+
statusText += ` · 等待响应 ${Math.floor(idleMs / 1000)}s`;
|
|
1674
|
+
}
|
|
1675
|
+
const statusLine = this.styleLine('system', fitLine(statusText));
|
|
1676
|
+
const paintRows = [
|
|
1677
|
+
...headerLines,
|
|
1678
|
+
...visible,
|
|
1679
|
+
...dialogLines,
|
|
1680
|
+
inputDivider,
|
|
1681
|
+
...suggestionLines,
|
|
1682
|
+
...inputDisplayLines,
|
|
1683
|
+
`${statsLine}\x1b[0m`,
|
|
1684
|
+
`${statusLine}\x1b[0m`,
|
|
1685
|
+
];
|
|
1686
|
+
// Bottom chrome is force-repainted whenever its state changes while the
|
|
1687
|
+
// agent is working; this clears any stale cell left behind by a previous
|
|
1688
|
+
// frame even when the row strings happen to be identical.
|
|
1689
|
+
const chromeStart = Math.max(0, paintRows.length - inputRows - 3);
|
|
1690
|
+
const chromeKey = [
|
|
1691
|
+
this.status,
|
|
1692
|
+
this.agent.status,
|
|
1693
|
+
this.scrollOffset,
|
|
1694
|
+
statsText,
|
|
1695
|
+
statusText,
|
|
1696
|
+
inputView.text,
|
|
1697
|
+
inputView.folded,
|
|
1698
|
+
inputRows,
|
|
1699
|
+
paintRows.length,
|
|
1700
|
+
this.pendingMessages.size,
|
|
1701
|
+
this.commandSuggestions.length,
|
|
1702
|
+
this.suggestionIndex,
|
|
1703
|
+
].join('\x1f');
|
|
1704
|
+
const chromeChanged = chromeKey !== this.lastChromeKey;
|
|
1705
|
+
// Incremental repaint: rewrite only rows whose content changed, so slow
|
|
1706
|
+
// SSH links don't rebuild (and flicker) the whole screen on every tick.
|
|
1707
|
+
this.write('\x1b[?25l');
|
|
1708
|
+
const maxRows = Math.max(paintRows.length, this.lastPaintRows.length);
|
|
1709
|
+
for (let i = 0; i < maxRows; i++) {
|
|
1710
|
+
const current = paintRows[i];
|
|
1711
|
+
if (current === this.lastPaintRows[i] && !(chromeChanged && i >= chromeStart))
|
|
1712
|
+
continue;
|
|
1713
|
+
this.write(`\x1b[${i + 1};1H\x1b[0m${current ?? ''}\x1b[K`);
|
|
1714
|
+
}
|
|
1715
|
+
if (paintRows.length < this.lastPaintRows.length) {
|
|
1716
|
+
this.write(`\x1b[${paintRows.length + 1};1H\x1b[J`);
|
|
1717
|
+
}
|
|
1718
|
+
this.write('\x1b[0m');
|
|
1719
|
+
this.lastPaintRows = paintRows;
|
|
1720
|
+
this.lastChromeKey = chromeKey;
|
|
1721
|
+
const cursorRowOffset = Math.min(Math.floor(cursorPlainOffset / grid), Math.max(0, inputRows - 1));
|
|
1722
|
+
let column = cursorPlainOffset % grid + 1;
|
|
1723
|
+
// A folded view is capped to one visual row; if the cursor still lands on
|
|
1724
|
+
// an exact boundary, keep it on the final occupied cell rather than
|
|
1725
|
+
// pointing at the row below.
|
|
1726
|
+
if (cursorPlainOffset > 0
|
|
1727
|
+
&& cursorPlainOffset % grid === 0
|
|
1728
|
+
&& Math.floor(cursorPlainOffset / grid) >= inputRows) {
|
|
1729
|
+
column = grid;
|
|
1730
|
+
}
|
|
1731
|
+
const inputTopRow = visible.length + dialogLines.length + suggestionLines.length + headerLines.length + 2;
|
|
1732
|
+
const row = Math.min(height, inputTopRow + cursorRowOffset);
|
|
1733
|
+
this.write(`\x1b[${row};${Math.max(1, column)}H\x1b[?25h`);
|
|
1734
|
+
};
|
|
1735
|
+
buildSuggestions() {
|
|
1736
|
+
const input = this.input;
|
|
1737
|
+
if (!input.startsWith('/'))
|
|
1738
|
+
return [];
|
|
1739
|
+
const prefix = input.slice(1).toLowerCase();
|
|
1740
|
+
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
|
|
1741
|
+
name: command.name,
|
|
1742
|
+
description: command.description,
|
|
1743
|
+
local: false,
|
|
1744
|
+
}));
|
|
1745
|
+
const all = [
|
|
1746
|
+
...LOCAL_COMMANDS.map(command => ({ name: command.name, description: command.description, local: true })),
|
|
1747
|
+
...dsh,
|
|
1748
|
+
];
|
|
1749
|
+
const filtered = prefix === ''
|
|
1750
|
+
? all
|
|
1751
|
+
: all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
|
|
1752
|
+
return filtered.slice(0, 12);
|
|
1753
|
+
}
|
|
1754
|
+
suggestionsVisible() {
|
|
1755
|
+
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
1756
|
+
}
|
|
1757
|
+
currentSelectionLabel() {
|
|
1758
|
+
const current = this.selectionRef?.current;
|
|
1759
|
+
const provider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
1760
|
+
const model = current?.model ?? this.agent.options.model ?? 'unknown';
|
|
1761
|
+
const effort = current?.reasoningEffort;
|
|
1762
|
+
return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`;
|
|
1763
|
+
}
|
|
1764
|
+
/** Replace one step's usage sample so a repeated report never double counts. */
|
|
1765
|
+
recordUsage(turn, step, usage) {
|
|
1766
|
+
const key = `${turn}:${step}`;
|
|
1767
|
+
const next = {
|
|
1768
|
+
inputTokens: usage.inputTokens,
|
|
1769
|
+
outputTokens: usage.outputTokens,
|
|
1770
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
1771
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
1772
|
+
};
|
|
1773
|
+
const previous = this.usageByStep.get(key);
|
|
1774
|
+
const totals = this.stats.usage;
|
|
1775
|
+
this.stats.usage = {
|
|
1776
|
+
inputTokens: totals.inputTokens - (previous?.inputTokens ?? 0) + next.inputTokens,
|
|
1777
|
+
outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
|
|
1778
|
+
cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
|
|
1779
|
+
cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
|
|
1780
|
+
};
|
|
1781
|
+
this.usageByStep.set(key, next);
|
|
1782
|
+
}
|
|
1783
|
+
/** The web-aligned session stats strip: counts, timings, cache, tokens. */
|
|
1784
|
+
statsText() {
|
|
1785
|
+
const stats = this.stats;
|
|
1786
|
+
const groups = [];
|
|
1787
|
+
if (stats.steps > 0)
|
|
1788
|
+
groups.push(`${stats.turns} 轮 · ${stats.steps} 步`);
|
|
1789
|
+
const durations = [];
|
|
1790
|
+
if (stats.llmMs > 0)
|
|
1791
|
+
durations.push(`模型 ${formatDuration(stats.llmMs)}`);
|
|
1792
|
+
if (stats.toolMs > 0)
|
|
1793
|
+
durations.push(`工具 ${formatDuration(stats.toolMs)}`);
|
|
1794
|
+
if (durations.length > 0)
|
|
1795
|
+
groups.push(durations.join(' · '));
|
|
1796
|
+
const speeds = [];
|
|
1797
|
+
if (stats.ttftSteps > 0)
|
|
1798
|
+
speeds.push(`首字 ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
1799
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
1800
|
+
speeds.push(formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)));
|
|
1801
|
+
}
|
|
1802
|
+
if (speeds.length > 0)
|
|
1803
|
+
groups.push(speeds.join(' · '));
|
|
1804
|
+
const usage = stats.usage;
|
|
1805
|
+
const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
1806
|
+
if (billedInput > 0 || usage.outputTokens > 0) {
|
|
1807
|
+
if (billedInput > 0) {
|
|
1808
|
+
groups.push(`缓存命中 ${Math.round(usage.cacheReadTokens / billedInput * 100)}%`);
|
|
1809
|
+
}
|
|
1810
|
+
groups.push(`输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}`);
|
|
1811
|
+
}
|
|
1812
|
+
return groups.join(' | ');
|
|
1813
|
+
}
|
|
1814
|
+
/** Refresh the terminal window title (throttled while running). */
|
|
1815
|
+
updateTerminalTitle() {
|
|
1816
|
+
if (this.exiting)
|
|
1817
|
+
return;
|
|
1818
|
+
const now = Date.now();
|
|
1819
|
+
// Completion wins over a still-running agent status: the turn/end event
|
|
1820
|
+
// lands before agent/status flips to idle, and the title must not stay
|
|
1821
|
+
// on the running spinner until the next repaint trigger.
|
|
1822
|
+
if (this.completedAt !== 0 && now - this.completedAt < 5000) {
|
|
1823
|
+
this.write('\x1b]0;dsh ✓ 已完成\x07');
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
if (this.agent.status === 'running') {
|
|
1827
|
+
if (now - this.lastTitleUpdateAt < 800)
|
|
1828
|
+
return;
|
|
1829
|
+
this.lastTitleUpdateAt = now;
|
|
1830
|
+
const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
|
|
1831
|
+
let detail = '运行中';
|
|
1832
|
+
if (this.openToolCalls.size > 0)
|
|
1833
|
+
detail = `运行中 · 工具 ${this.openToolCalls.size}`;
|
|
1834
|
+
else if (this.activeSubagents.size > 0)
|
|
1835
|
+
detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
|
|
1836
|
+
this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
this.write('\x1b]0;dsh 待命\x07');
|
|
1840
|
+
}
|
|
1841
|
+
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
1842
|
+
playCompletionSignal() {
|
|
1843
|
+
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
1844
|
+
if (disabled)
|
|
1845
|
+
return;
|
|
1846
|
+
process.stdout.write('\x07');
|
|
1847
|
+
}
|
|
1848
|
+
render = () => {
|
|
1849
|
+
if (!this.dirty || this.exiting)
|
|
1850
|
+
return;
|
|
1851
|
+
if (this.agent.status === 'running'
|
|
1852
|
+
&& Date.now() - this.lastActivity > STALL_WARNING_MS
|
|
1853
|
+
&& !this.stalledWarningShown
|
|
1854
|
+
&& this.openToolCalls.size === 0
|
|
1855
|
+
&& this.activeSubagents.size === 0) {
|
|
1856
|
+
this.stalledWarningShown = true;
|
|
1857
|
+
this.pushRow({ kind: 'error', text: '模型/工具长时间无响应,可按 Esc 或 Ctrl+C 中断当前轮次。' });
|
|
1858
|
+
this.markDirty();
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
this.dirty = false;
|
|
1862
|
+
this.paint();
|
|
1863
|
+
};
|
|
1864
|
+
styleLine(kind, text) {
|
|
1865
|
+
if (!this.color)
|
|
1866
|
+
return text;
|
|
1867
|
+
const code = kind === 'user' ? '36' :
|
|
1868
|
+
kind === 'assistant' ? '1;37' :
|
|
1869
|
+
kind === 'reasoning' ? '2;3' :
|
|
1870
|
+
kind === 'brand' ? '1;38;2;77;107;253' :
|
|
1871
|
+
kind === 'tool' || kind === 'tool-result' ? '33' :
|
|
1872
|
+
kind === 'diff-add' ? '38;5;22;48;5;194' :
|
|
1873
|
+
kind === 'diff-del' ? '38;5;124;48;5;224' :
|
|
1874
|
+
kind === 'diff-path' ? '1;36' :
|
|
1875
|
+
kind === 'error' ? '31' :
|
|
1876
|
+
'90';
|
|
1877
|
+
return `\x1b[${code}m${text}`;
|
|
1878
|
+
}
|
|
1879
|
+
// ── event handling ──────────────────────────────────────────────────────
|
|
1880
|
+
handleSessionEvent = (session, event) => {
|
|
1881
|
+
if (session.id !== this.agent.id) {
|
|
1882
|
+
if (this.subagentSessions.has(session.id))
|
|
1883
|
+
this.handleSubagentSessionEvent(session.id, event);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
this.lastActivity = Date.now();
|
|
1887
|
+
switch (event.type) {
|
|
1888
|
+
case 'user/message': {
|
|
1889
|
+
const text = event.data.content
|
|
1890
|
+
.filter(block => block.type === 'text')
|
|
1891
|
+
.map(block => block.text)
|
|
1892
|
+
.join('');
|
|
1893
|
+
if (text !== '') {
|
|
1894
|
+
const sourceKind = event.data.source.kind;
|
|
1895
|
+
if (sourceKind === 'user') {
|
|
1896
|
+
this.pushRow({ kind: 'user', text: `❯ ${text}` });
|
|
1897
|
+
}
|
|
1898
|
+
else if (sourceKind === 'plugin' && event.data.source.form === 'snapshot') {
|
|
1899
|
+
this.pushRow({ kind: 'system', text: text });
|
|
1900
|
+
}
|
|
1901
|
+
else {
|
|
1902
|
+
this.pushRow({ kind: 'system', text: `(context) ${text}` });
|
|
1903
|
+
}
|
|
1904
|
+
this.streaming = undefined;
|
|
1905
|
+
this.streamingReasoning = undefined;
|
|
1906
|
+
this.markDirty();
|
|
1907
|
+
}
|
|
1908
|
+
break;
|
|
1909
|
+
}
|
|
1910
|
+
case 'assistant/chunk': {
|
|
1911
|
+
const chunk = event.data.chunk;
|
|
1912
|
+
const open = this.openStepStats;
|
|
1913
|
+
if (open !== null && open !== undefined
|
|
1914
|
+
&& open.turn === event.data.turn && open.step === event.data.step) {
|
|
1915
|
+
if (open.firstTokenTime === null
|
|
1916
|
+
&& chunk.type === 'text-delta'
|
|
1917
|
+
&& chunk.text !== '') {
|
|
1918
|
+
this.openStepStats = { ...open, firstTokenTime: event.time };
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
if (chunk.type === 'usage' && chunk.usage !== undefined) {
|
|
1922
|
+
this.recordUsage(event.data.turn, event.data.step, chunk.usage);
|
|
1923
|
+
}
|
|
1924
|
+
if (chunk.type === 'text-delta') {
|
|
1925
|
+
this.streaming ??= { text: '', reasoning: '' };
|
|
1926
|
+
this.streaming.text += chunk.text;
|
|
1927
|
+
this.markDirty();
|
|
1928
|
+
}
|
|
1929
|
+
else if (chunk.type === 'reasoning-delta') {
|
|
1930
|
+
this.streaming ??= { text: '', reasoning: '' };
|
|
1931
|
+
if (this.streaming.reasoning === '' && chunk.text !== '') {
|
|
1932
|
+
this.thinkingStartedAt = Date.now();
|
|
1933
|
+
this.streamingReasoning = { kind: 'streaming-reasoning', expanded: false };
|
|
1934
|
+
}
|
|
1935
|
+
this.streaming.reasoning += chunk.text;
|
|
1936
|
+
this.markDirty();
|
|
1937
|
+
}
|
|
1938
|
+
break;
|
|
1939
|
+
}
|
|
1940
|
+
case 'assistant/message': {
|
|
1941
|
+
const text = event.data.message.content
|
|
1942
|
+
.filter(block => block.type === 'text')
|
|
1943
|
+
.map(block => block.text)
|
|
1944
|
+
.join('');
|
|
1945
|
+
const reasoning = event.data.message.content
|
|
1946
|
+
.filter(block => block.type === 'reasoning')
|
|
1947
|
+
.map(block => block.text)
|
|
1948
|
+
.join('');
|
|
1949
|
+
const open = this.openStepStats;
|
|
1950
|
+
if (open !== undefined && open.turn === event.data.turn && open.step === event.data.step) {
|
|
1951
|
+
this.stats.llmMs += Math.max(0, event.time - open.startTime);
|
|
1952
|
+
if (open.firstTokenTime !== null) {
|
|
1953
|
+
this.stats.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
|
|
1954
|
+
this.stats.ttftSteps += 1;
|
|
1955
|
+
const outputTokens = event.data.usage?.outputTokens;
|
|
1956
|
+
if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
|
|
1957
|
+
this.stats.decodeMs += Math.max(0, event.time - open.firstTokenTime);
|
|
1958
|
+
this.stats.decodeTokens += outputTokens;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
this.openStepStats = undefined;
|
|
1962
|
+
}
|
|
1963
|
+
if (event.data.usage !== undefined) {
|
|
1964
|
+
this.recordUsage(event.data.turn, event.data.step, event.data.usage);
|
|
1965
|
+
}
|
|
1966
|
+
const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
|
|
1967
|
+
this.streaming = undefined;
|
|
1968
|
+
this.streamingReasoning = undefined;
|
|
1969
|
+
this.thinkingStartedAt = undefined;
|
|
1970
|
+
if (reasoning !== '') {
|
|
1971
|
+
this.pushRow({ kind: 'reasoning', text: reasoning, expanded: reasoningExpanded });
|
|
1972
|
+
}
|
|
1973
|
+
if (text !== '')
|
|
1974
|
+
this.pushRow({ kind: 'assistant', text });
|
|
1975
|
+
this.markDirty();
|
|
1976
|
+
break;
|
|
1977
|
+
}
|
|
1978
|
+
case 'tool/call': {
|
|
1979
|
+
this.openToolCalls.set(String(event.data.callId), event.data.name);
|
|
1980
|
+
this.pendingToolTimes.set(String(event.data.callId), event.time);
|
|
1981
|
+
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
1982
|
+
const row = {
|
|
1983
|
+
kind: 'tool',
|
|
1984
|
+
callId: event.data.callId,
|
|
1985
|
+
name: event.data.name,
|
|
1986
|
+
args: event.data.arguments,
|
|
1987
|
+
status: 'running',
|
|
1988
|
+
output: '',
|
|
1989
|
+
title: present.title,
|
|
1990
|
+
summary: present.summary,
|
|
1991
|
+
...present.command === undefined ? {} : { command: present.command },
|
|
1992
|
+
...present.cwd === undefined ? {} : { cwd: present.cwd },
|
|
1993
|
+
...present.diff === undefined ? {} : { diff: present.diff },
|
|
1994
|
+
expanded: DIFF_TOOL_NAMES.has(event.data.name),
|
|
1995
|
+
};
|
|
1996
|
+
this.pushRow(row);
|
|
1997
|
+
this.streaming = undefined;
|
|
1998
|
+
this.markDirty();
|
|
1999
|
+
break;
|
|
2000
|
+
}
|
|
2001
|
+
case 'tool/result': {
|
|
2002
|
+
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
2003
|
+
const dispatchedAt = this.pendingToolTimes.get(String(event.data.message.source.callId));
|
|
2004
|
+
if (dispatchedAt !== undefined) {
|
|
2005
|
+
this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
|
|
2006
|
+
this.pendingToolTimes.delete(String(event.data.message.source.callId));
|
|
2007
|
+
}
|
|
2008
|
+
const row = this.rows.findLast((candidate) => candidate.kind === 'tool' && candidate.callId === event.data.message.source.callId);
|
|
2009
|
+
const output = collectText(event.data.message.content);
|
|
2010
|
+
if (row !== undefined) {
|
|
2011
|
+
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
2012
|
+
if (metaDiffs !== null) {
|
|
2013
|
+
row.diff = metaDiffs;
|
|
2014
|
+
if (DIFF_TOOL_NAMES.has(row.name))
|
|
2015
|
+
row.expanded = true;
|
|
2016
|
+
}
|
|
2017
|
+
const isShell = SHELL_TOOL_NAMES.has(row.name);
|
|
2018
|
+
if (isShell) {
|
|
2019
|
+
const parsed = parseExitStatus(output);
|
|
2020
|
+
row.output = parsed.body;
|
|
2021
|
+
if (parsed.signal !== undefined)
|
|
2022
|
+
row.signal = parsed.signal;
|
|
2023
|
+
else
|
|
2024
|
+
row.exitCode = parsed.exitCode;
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
row.output = output;
|
|
2028
|
+
}
|
|
2029
|
+
const failed = event.data.error !== undefined
|
|
2030
|
+
|| event.data.message.content[0]?.isError === true
|
|
2031
|
+
|| (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
|
|
2032
|
+
row.status = failed ? 'error' : 'ok';
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
const present = presentToolCall(event.data.message.source.callId, '');
|
|
2036
|
+
this.pushRow({
|
|
2037
|
+
kind: 'tool',
|
|
2038
|
+
callId: event.data.message.source.callId,
|
|
2039
|
+
name: event.data.message.source.callId,
|
|
2040
|
+
args: '',
|
|
2041
|
+
status: event.data.error === undefined ? 'ok' : 'error',
|
|
2042
|
+
output,
|
|
2043
|
+
title: present.title,
|
|
2044
|
+
summary: present.summary,
|
|
2045
|
+
expanded: false,
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
this.markDirty();
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
case 'step/start': {
|
|
2052
|
+
this.openStepStats = {
|
|
2053
|
+
turn: event.data.turn,
|
|
2054
|
+
step: event.data.step,
|
|
2055
|
+
startTime: event.time,
|
|
2056
|
+
firstTokenTime: null,
|
|
2057
|
+
};
|
|
2058
|
+
this.markDirty();
|
|
2059
|
+
break;
|
|
2060
|
+
}
|
|
2061
|
+
case 'step/end': {
|
|
2062
|
+
if (this.lastStatsTurn !== event.data.turn) {
|
|
2063
|
+
this.stats.turns += 1;
|
|
2064
|
+
this.lastStatsTurn = event.data.turn;
|
|
2065
|
+
}
|
|
2066
|
+
this.stats.steps += 1;
|
|
2067
|
+
this.openStepStats = undefined;
|
|
2068
|
+
this.markDirty();
|
|
2069
|
+
break;
|
|
2070
|
+
}
|
|
2071
|
+
case 'turn/start':
|
|
2072
|
+
this.stalledWarningShown = false;
|
|
2073
|
+
this.status = `turn ${event.data.turn} running`;
|
|
2074
|
+
this.markDirty();
|
|
2075
|
+
break;
|
|
2076
|
+
case 'turn/end': {
|
|
2077
|
+
const reason = event.data.reason;
|
|
2078
|
+
this.openToolCalls.clear();
|
|
2079
|
+
this.pendingToolTimes.clear();
|
|
2080
|
+
this.stalledWarningShown = false;
|
|
2081
|
+
this.pendingMessages.clear();
|
|
2082
|
+
if (reason.kind === 'completed' && !this.completionSignaled) {
|
|
2083
|
+
this.completionSignaled = true;
|
|
2084
|
+
this.completedAt = Date.now();
|
|
2085
|
+
this.updateTerminalTitle();
|
|
2086
|
+
this.playCompletionSignal();
|
|
2087
|
+
}
|
|
2088
|
+
this.status = reason.kind === 'completed'
|
|
2089
|
+
? 'idle'
|
|
2090
|
+
: reason.kind === 'error'
|
|
2091
|
+
? `error: ${reason.error.message}`
|
|
2092
|
+
: `idle (${reason.kind})`;
|
|
2093
|
+
if (reason.kind === 'error') {
|
|
2094
|
+
this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
|
|
2095
|
+
}
|
|
2096
|
+
this.markDirty();
|
|
2097
|
+
break;
|
|
2098
|
+
}
|
|
2099
|
+
default:
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
handleStatus = ({ agent, status }) => {
|
|
2104
|
+
if (agent !== this.agent)
|
|
2105
|
+
return;
|
|
2106
|
+
this.lastActivity = Date.now();
|
|
2107
|
+
if (status === 'running') {
|
|
2108
|
+
this.completionSignaled = false;
|
|
2109
|
+
}
|
|
2110
|
+
else if (!this.completionSignaled && this.status === 'running') {
|
|
2111
|
+
this.completionSignaled = true;
|
|
2112
|
+
this.completedAt = Date.now();
|
|
2113
|
+
this.updateTerminalTitle();
|
|
2114
|
+
this.playCompletionSignal();
|
|
2115
|
+
}
|
|
2116
|
+
this.status = status === 'running' ? 'running' : 'idle';
|
|
2117
|
+
this.markDirty();
|
|
2118
|
+
};
|
|
2119
|
+
handleError = ({ agent, error }) => {
|
|
2120
|
+
if (agent !== this.agent)
|
|
2121
|
+
return;
|
|
2122
|
+
this.lastActivity = Date.now();
|
|
2123
|
+
this.pushRow({ kind: 'error', text: errorChain(error) });
|
|
2124
|
+
this.markDirty();
|
|
2125
|
+
};
|
|
2126
|
+
handleInboxClaimed = ({ agent, message }) => {
|
|
2127
|
+
if (agent !== this.agent)
|
|
2128
|
+
return;
|
|
2129
|
+
if (this.pendingMessages.delete(message.id))
|
|
2130
|
+
this.markDirty();
|
|
2131
|
+
};
|
|
2132
|
+
handleInboxDiscarded = ({ agent, message }) => {
|
|
2133
|
+
if (agent !== this.agent)
|
|
2134
|
+
return;
|
|
2135
|
+
if (this.pendingMessages.delete(message.id))
|
|
2136
|
+
this.markDirty();
|
|
2137
|
+
};
|
|
2138
|
+
handleDisposed = ({ agent }) => {
|
|
2139
|
+
if (agent !== this.agent)
|
|
2140
|
+
return;
|
|
2141
|
+
this.agentGone = true;
|
|
2142
|
+
this.pushRow({ kind: 'error', text: 'Agent was disposed; press Ctrl+C to exit.' });
|
|
2143
|
+
this.status = 'disposed';
|
|
2144
|
+
this.markDirty();
|
|
2145
|
+
};
|
|
2146
|
+
/** Render a live subagent's own session events so its progress is visible. */
|
|
2147
|
+
handleSubagentSessionEvent = (sessionId, event) => {
|
|
2148
|
+
const label = `[子代理 ${String(sessionId).slice(0, 8)}]`;
|
|
2149
|
+
switch (event.type) {
|
|
2150
|
+
case 'user/message': {
|
|
2151
|
+
const text = collectText(event.data.content);
|
|
2152
|
+
if (text !== '')
|
|
2153
|
+
this.pushRow({ kind: 'system', text: `${label} ❯ ${truncate(text, 6)}` });
|
|
2154
|
+
break;
|
|
2155
|
+
}
|
|
2156
|
+
case 'assistant/chunk': {
|
|
2157
|
+
// Child chunks are coalesced into assistant/message to avoid flooding.
|
|
2158
|
+
break;
|
|
2159
|
+
}
|
|
2160
|
+
case 'assistant/message': {
|
|
2161
|
+
const text = collectText(event.data.message.content);
|
|
2162
|
+
if (text !== '')
|
|
2163
|
+
this.pushRow({ kind: 'assistant', text: `${label} ${truncate(text, 12)}` });
|
|
2164
|
+
break;
|
|
2165
|
+
}
|
|
2166
|
+
case 'tool/call':
|
|
2167
|
+
this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${event.data.arguments.slice(0, 160)}` });
|
|
2168
|
+
break;
|
|
2169
|
+
case 'tool/result': {
|
|
2170
|
+
const output = truncate(collectText(event.data.message.content), 4);
|
|
2171
|
+
const ok = event.data.error === undefined && !event.data.message.content[0]?.isError;
|
|
2172
|
+
this.pushRow({ kind: 'system', text: `${label} ${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : `\n ${output}`}` });
|
|
2173
|
+
break;
|
|
2174
|
+
}
|
|
2175
|
+
case 'turn/end':
|
|
2176
|
+
this.pushRow({ kind: 'system', text: `${label} 轮次结束(${event.data.reason.kind})` });
|
|
2177
|
+
break;
|
|
2178
|
+
case 'approval/asked':
|
|
2179
|
+
this.pushRow({ kind: 'system', text: `${label} 等待审批:${event.data.toolName}` });
|
|
2180
|
+
break;
|
|
2181
|
+
default:
|
|
2182
|
+
break;
|
|
2183
|
+
}
|
|
2184
|
+
this.lastActivity = Date.now();
|
|
2185
|
+
this.markDirty();
|
|
2186
|
+
};
|
|
2187
|
+
handleSubagentStart = (info) => {
|
|
2188
|
+
this.activeSubagents.set(String(info.runId), {
|
|
2189
|
+
id: String(info.id),
|
|
2190
|
+
provider: info.provider,
|
|
2191
|
+
startedAt: Date.now(),
|
|
2192
|
+
});
|
|
2193
|
+
this.subagentSessions.add(String(info.id));
|
|
2194
|
+
this.lastActivity = Date.now();
|
|
2195
|
+
this.pushRow({ kind: 'system', text: `▶ 子代理 ${info.id} 已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
|
|
2196
|
+
this.markDirty();
|
|
2197
|
+
};
|
|
2198
|
+
handleSubagentEnd = (info) => {
|
|
2199
|
+
this.activeSubagents.delete(String(info.runId));
|
|
2200
|
+
this.subagentSessions.delete(String(info.id));
|
|
2201
|
+
this.lastActivity = Date.now();
|
|
2202
|
+
const output = info.lastAssistantMessage === undefined
|
|
2203
|
+
? ''
|
|
2204
|
+
: truncate(collectText(info.lastAssistantMessage), 6);
|
|
2205
|
+
this.pushRow({
|
|
2206
|
+
kind: 'system',
|
|
2207
|
+
text: `✓ 子代理 ${info.id} 结束(${info.stopReason})${output === '' ? '' : `\n ${output}`}`,
|
|
2208
|
+
});
|
|
2209
|
+
this.markDirty();
|
|
2210
|
+
};
|
|
2211
|
+
// ── approval and questions ──────────────────────────────────────────────
|
|
2212
|
+
handleApproval = async (request, next) => {
|
|
2213
|
+
const agentLabel = request.agent.id === this.agent.id
|
|
2214
|
+
? '当前会话'
|
|
2215
|
+
: `子代理 ${request.agent.id}`;
|
|
2216
|
+
return new Promise((resolve) => {
|
|
2217
|
+
const onAbort = () => {
|
|
2218
|
+
this.closeConfirm('cancel');
|
|
2219
|
+
};
|
|
2220
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2221
|
+
this.openConfirm(`允许工具 "${request.toolName}"?(${agentLabel})${request.reason === undefined ? '' : `\n${request.reason}`}`, 'y = 允许一次, n = 拒绝, Esc = 取消', (answer) => {
|
|
2222
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2223
|
+
resolve(answer === 'y' ? 'allowed-once' : answer === 'n' ? 'rejected' : 'cancelled');
|
|
2224
|
+
});
|
|
2225
|
+
});
|
|
2226
|
+
};
|
|
2227
|
+
handleUserQuestions = async (request) => {
|
|
2228
|
+
const answers = [];
|
|
2229
|
+
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
2230
|
+
? undefined
|
|
2231
|
+
: `子代理 ${request.agent.id}`;
|
|
2232
|
+
for (const [index, question] of request.questions.entries()) {
|
|
2233
|
+
const answer = await new Promise((resolve, reject) => {
|
|
2234
|
+
const onAbort = () => {
|
|
2235
|
+
this.dialog = undefined;
|
|
2236
|
+
reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
2237
|
+
};
|
|
2238
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2239
|
+
const labeled = agentLabel === undefined
|
|
2240
|
+
? question
|
|
2241
|
+
: { ...question, question: `[${agentLabel}] ${question.question}` };
|
|
2242
|
+
this.openQuestion(labeled, index, request.questions.length, (selection) => {
|
|
2243
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
2244
|
+
resolve(selection);
|
|
2245
|
+
}, reject);
|
|
2246
|
+
});
|
|
2247
|
+
answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
|
|
2248
|
+
}
|
|
2249
|
+
return { answers };
|
|
2250
|
+
};
|
|
2251
|
+
openConfirm(prompt, hint, resolve) {
|
|
2252
|
+
this.dialog = { kind: 'confirm', prompt, hint, resolve };
|
|
2253
|
+
this.markDirty();
|
|
2254
|
+
}
|
|
2255
|
+
closeConfirm(value) {
|
|
2256
|
+
const dialog = this.dialog;
|
|
2257
|
+
if (dialog === undefined || dialog.kind !== 'confirm')
|
|
2258
|
+
return;
|
|
2259
|
+
this.dialog = undefined;
|
|
2260
|
+
dialog.resolve(value);
|
|
2261
|
+
this.markDirty();
|
|
2262
|
+
}
|
|
2263
|
+
openQuestion(question, index, total, resolve, reject) {
|
|
2264
|
+
this.dialog = {
|
|
2265
|
+
kind: 'questions',
|
|
2266
|
+
question,
|
|
2267
|
+
index,
|
|
2268
|
+
total,
|
|
2269
|
+
selected: new Set(),
|
|
2270
|
+
resolve: (selection) => {
|
|
2271
|
+
this.dialog = undefined;
|
|
2272
|
+
resolve(selection);
|
|
2273
|
+
this.markDirty();
|
|
2274
|
+
},
|
|
2275
|
+
reject: (error) => {
|
|
2276
|
+
this.dialog = undefined;
|
|
2277
|
+
reject(error);
|
|
2278
|
+
this.markDirty();
|
|
2279
|
+
},
|
|
2280
|
+
};
|
|
2281
|
+
this.markDirty();
|
|
2282
|
+
}
|
|
2283
|
+
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
2284
|
+
askQuestion(question, index = 0, total = 1) {
|
|
2285
|
+
return new Promise((resolve, reject) => {
|
|
2286
|
+
this.openQuestion(question, index, total, resolve, reject);
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
/** The stored llm-pi-ai profile for one provider route, when settings provide one. */
|
|
2290
|
+
piAiProviderProfile(provider) {
|
|
2291
|
+
if (provider === 'deepseek-official')
|
|
2292
|
+
return undefined;
|
|
2293
|
+
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
2294
|
+
return section?.providers?.[provider];
|
|
2295
|
+
}
|
|
2296
|
+
/** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
|
|
2297
|
+
openCodeListingBaseURL(provider) {
|
|
2298
|
+
if (provider === 'opencode-go')
|
|
2299
|
+
return PROVIDER_TEMPLATES['opencode-go'].defaultBaseUrl;
|
|
2300
|
+
if (provider === 'opencode')
|
|
2301
|
+
return OPENCODE_ZEN_BASE_URL;
|
|
2302
|
+
return undefined;
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Fetch the live model list for an OpenCode or third-party provider from its
|
|
2306
|
+
* OpenAI-compatible listing endpoint. The provider route is deliberately not
|
|
2307
|
+
* passed to discovery: pi-ai would answer a catalog route from its installed
|
|
2308
|
+
* registry, while the TUI wants the endpoint's current list.
|
|
2309
|
+
*/
|
|
2310
|
+
async discoverEndpointModels(provider) {
|
|
2311
|
+
const profile = this.piAiProviderProfile(provider);
|
|
2312
|
+
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
2313
|
+
? profile.baseURL.trim()
|
|
2314
|
+
: this.openCodeListingBaseURL(provider);
|
|
2315
|
+
if (baseURL === undefined)
|
|
2316
|
+
return [];
|
|
2317
|
+
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
2318
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
2319
|
+
? profile.apiKeyEnv.trim()
|
|
2320
|
+
: undefined;
|
|
2321
|
+
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
2322
|
+
const llm = this.ctx.get('llm');
|
|
2323
|
+
if (llm === undefined)
|
|
2324
|
+
return [];
|
|
2325
|
+
const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
|
|
2326
|
+
baseURL,
|
|
2327
|
+
...(api === undefined ? {} : { api }),
|
|
2328
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
2329
|
+
signal: AbortSignal.timeout(15_000),
|
|
2330
|
+
});
|
|
2331
|
+
return discovered.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
2332
|
+
}
|
|
2333
|
+
/** Add one endpoint-listed model to the stored provider profile when needed. */
|
|
2334
|
+
async ensureProviderModelConfigured(provider, modelId) {
|
|
2335
|
+
const settings = this.ctx.get('settings');
|
|
2336
|
+
const profile = this.piAiProviderProfile(provider);
|
|
2337
|
+
if (settings === undefined || profile === undefined)
|
|
2338
|
+
return true;
|
|
2339
|
+
// A profile may legitimately have no models yet (e.g. onboarding saved an
|
|
2340
|
+
// empty list); the picked endpoint model must still be persisted so the
|
|
2341
|
+
// harness can serve it.
|
|
2342
|
+
const models = Array.isArray(profile.models) ? profile.models : [];
|
|
2343
|
+
const ids = new Set();
|
|
2344
|
+
for (const raw of models) {
|
|
2345
|
+
const id = typeof raw === 'string'
|
|
2346
|
+
? raw
|
|
2347
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
2348
|
+
? raw.id
|
|
2349
|
+
: undefined;
|
|
2350
|
+
if (typeof id === 'string' && id.length > 0)
|
|
2351
|
+
ids.add(id);
|
|
2352
|
+
}
|
|
2353
|
+
if (ids.has(modelId))
|
|
2354
|
+
return true;
|
|
2355
|
+
try {
|
|
2356
|
+
await settings.mutate(settingsNamespace('llm-pi-ai'), [
|
|
2357
|
+
{ op: 'set', path: ['providers', provider, 'models'], value: [...models, { id: modelId }] },
|
|
2358
|
+
]);
|
|
2359
|
+
this.pushRow({ kind: 'system', text: `模型 ${modelId} 已加入提供商 ${provider} 的配置。` });
|
|
2360
|
+
this.markDirty();
|
|
2361
|
+
return true;
|
|
2362
|
+
}
|
|
2363
|
+
catch (error) {
|
|
2364
|
+
this.pushRow({ kind: 'error', text: `无法把模型 ${modelId} 写入提供商配置:${errorChain(error)}` });
|
|
2365
|
+
this.markDirty();
|
|
2366
|
+
return false;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
/** How many endpoint-listed models fit on one picker page alongside navigation. */
|
|
2370
|
+
MODEL_PAGE_SIZE = 7;
|
|
2371
|
+
MODEL_PAGE_PREV = '« 上一页';
|
|
2372
|
+
MODEL_PAGE_NEXT = '» 下一页';
|
|
2373
|
+
/**
|
|
2374
|
+
* One pick across a possibly long model list, paging through the digit
|
|
2375
|
+
* dialog so an endpoint with dozens of models stays selectable.
|
|
2376
|
+
*/
|
|
2377
|
+
async pickModelOption(modelOptions, provider, sourceLabel, currentModel) {
|
|
2378
|
+
const seen = new Set();
|
|
2379
|
+
const unique = modelOptions.filter(option => {
|
|
2380
|
+
if (seen.has(option.id))
|
|
2381
|
+
return false;
|
|
2382
|
+
seen.add(option.id);
|
|
2383
|
+
return true;
|
|
2384
|
+
});
|
|
2385
|
+
if (unique.length === 0)
|
|
2386
|
+
return undefined;
|
|
2387
|
+
let offset = 0;
|
|
2388
|
+
for (;;) {
|
|
2389
|
+
const page = unique.slice(offset, offset + this.MODEL_PAGE_SIZE);
|
|
2390
|
+
const hasPrev = offset > 0;
|
|
2391
|
+
const hasNext = offset + this.MODEL_PAGE_SIZE < unique.length;
|
|
2392
|
+
const pageCount = Math.max(1, Math.ceil(unique.length / this.MODEL_PAGE_SIZE));
|
|
2393
|
+
const currentPage = Math.floor(offset / this.MODEL_PAGE_SIZE) + 1;
|
|
2394
|
+
const options = page.map(option => ({
|
|
2395
|
+
label: option.label,
|
|
2396
|
+
description: option.id === currentModel ? '当前' : undefined,
|
|
2397
|
+
}));
|
|
2398
|
+
if (hasPrev)
|
|
2399
|
+
options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
|
|
2400
|
+
if (hasNext)
|
|
2401
|
+
options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
|
|
2402
|
+
const answer = await this.askQuestion({
|
|
2403
|
+
id: 'model-pick',
|
|
2404
|
+
question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
|
|
2405
|
+
options,
|
|
2406
|
+
});
|
|
2407
|
+
const picked = options.find(option => option.label === answer.selected[0]);
|
|
2408
|
+
if (picked === undefined)
|
|
2409
|
+
return undefined;
|
|
2410
|
+
if (picked.label === this.MODEL_PAGE_NEXT) {
|
|
2411
|
+
offset += this.MODEL_PAGE_SIZE;
|
|
2412
|
+
continue;
|
|
2413
|
+
}
|
|
2414
|
+
if (picked.label === this.MODEL_PAGE_PREV) {
|
|
2415
|
+
offset = Math.max(0, offset - this.MODEL_PAGE_SIZE);
|
|
2416
|
+
continue;
|
|
2417
|
+
}
|
|
2418
|
+
return page.find(option => option.label === picked.label);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
/** /model: pick a model and reasoning effort for the current provider. */
|
|
2422
|
+
async runModelCommand() {
|
|
2423
|
+
const llm = this.ctx.get('llm');
|
|
2424
|
+
const current = this.selectionRef?.current;
|
|
2425
|
+
const provider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
2426
|
+
let modelOptions = [];
|
|
2427
|
+
let modelSource = '已配置列表';
|
|
2428
|
+
// OpenCode and other third-party routes are interrogated live so the picker
|
|
2429
|
+
// shows what the endpoint actually serves, not just the stored catalog.
|
|
2430
|
+
if (this.piAiProviderProfile(provider) !== undefined || provider === 'opencode' || provider === 'opencode-go') {
|
|
2431
|
+
const previousStatus = this.status;
|
|
2432
|
+
try {
|
|
2433
|
+
this.status = `正在从端点获取 ${provider} 的模型列表…`;
|
|
2434
|
+
this.markDirty();
|
|
2435
|
+
modelOptions = await this.discoverEndpointModels(provider);
|
|
2436
|
+
if (modelOptions.length > 0) {
|
|
2437
|
+
modelSource = '端点实时列表';
|
|
2438
|
+
// Keep models the endpoint does not list (e.g. ones already stored
|
|
2439
|
+
// for the route) selectable, so the live list never hides the
|
|
2440
|
+
// current model.
|
|
2441
|
+
try {
|
|
2442
|
+
const listed = (await llm?.listModels(provider)) ?? [];
|
|
2443
|
+
const endpointIds = new Set(modelOptions.map(model => model.id));
|
|
2444
|
+
for (const model of listed) {
|
|
2445
|
+
if (!endpointIds.has(model.id)) {
|
|
2446
|
+
modelOptions.push({ id: model.id, label: model.name || model.id });
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
catch {
|
|
2451
|
+
// The endpoint list stands alone when the catalog cannot be read.
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
catch {
|
|
2456
|
+
modelOptions = [];
|
|
2457
|
+
}
|
|
2458
|
+
finally {
|
|
2459
|
+
this.status = previousStatus;
|
|
2460
|
+
this.markDirty();
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (modelOptions.length === 0) {
|
|
2464
|
+
try {
|
|
2465
|
+
const listed = (await llm?.listModels(provider)) ?? [];
|
|
2466
|
+
modelOptions = listed.map(model => ({ id: model.id, label: model.name || model.id }));
|
|
2467
|
+
}
|
|
2468
|
+
catch {
|
|
2469
|
+
modelOptions = [];
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (modelOptions.length === 0) {
|
|
2473
|
+
const fallback = current?.model ?? this.agent.options.model ?? 'deepseek-v4-flash';
|
|
2474
|
+
modelOptions = [{ id: fallback, label: fallback }];
|
|
2475
|
+
}
|
|
2476
|
+
const selected = await this.pickModelOption(modelOptions, provider, modelSource, current?.model);
|
|
2477
|
+
if (selected === undefined)
|
|
2478
|
+
return;
|
|
2479
|
+
if (!(await this.ensureProviderModelConfigured(provider, selected.id)))
|
|
2480
|
+
return;
|
|
2481
|
+
let effortOptions = [];
|
|
2482
|
+
try {
|
|
2483
|
+
const info = await llm?.resolveModelInfo(provider, selected.id);
|
|
2484
|
+
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
2485
|
+
}
|
|
2486
|
+
catch {
|
|
2487
|
+
effortOptions = [];
|
|
2488
|
+
}
|
|
2489
|
+
if (effortOptions.length === 0) {
|
|
2490
|
+
effortOptions = ['off', 'high', 'max'].map(id => ({ id, label: id }));
|
|
2491
|
+
}
|
|
2492
|
+
const effortAnswer = await this.askQuestion({
|
|
2493
|
+
id: 'effort-pick',
|
|
2494
|
+
question: `选择思考强度(${selected.id})`,
|
|
2495
|
+
options: effortOptions.map(option => ({
|
|
2496
|
+
label: option.label,
|
|
2497
|
+
description: option.id === String(current?.reasoningEffort) ? '当前' : undefined,
|
|
2498
|
+
})),
|
|
2499
|
+
});
|
|
2500
|
+
const effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
2501
|
+
const next = {
|
|
2502
|
+
provider,
|
|
2503
|
+
model: selected.id,
|
|
2504
|
+
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
2505
|
+
};
|
|
2506
|
+
if (this.selectionRef !== undefined)
|
|
2507
|
+
this.selectionRef.current = next;
|
|
2508
|
+
this.onSelectionChanged?.(next);
|
|
2509
|
+
await this.ctx.get('agentDefaultModel')?.saveSelection(next);
|
|
2510
|
+
this.pushRow({ kind: 'system', text: `模型已切换:${selected.id}(思考强度 ${effort ?? '默认'});下一步请求生效。` });
|
|
2511
|
+
this.markDirty();
|
|
2512
|
+
}
|
|
2513
|
+
/** /mode: pick an agent preset (standard / PTC / minimal / ...). */
|
|
2514
|
+
async runModeCommand() {
|
|
2515
|
+
const agentPresets = this.ctx.get('agentPresets');
|
|
2516
|
+
if (agentPresets === undefined) {
|
|
2517
|
+
this.pushRow({ kind: 'error', text: 'agentPresets 服务不可用。' });
|
|
2518
|
+
this.markDirty();
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const presets = await agentPresets.list();
|
|
2522
|
+
if (presets.length === 0) {
|
|
2523
|
+
this.pushRow({ kind: 'error', text: '没有可用的模式(preset)。' });
|
|
2524
|
+
this.markDirty();
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
const answer = await this.askQuestion({
|
|
2528
|
+
id: 'mode-pick',
|
|
2529
|
+
question: '选择模式',
|
|
2530
|
+
options: presets.map(preset => ({
|
|
2531
|
+
label: preset.name ?? preset.id,
|
|
2532
|
+
description: `${preset.id === this.presetId ? '当前 · ' : ''}${preset.description ?? ''}`.trim(),
|
|
2533
|
+
})),
|
|
2534
|
+
});
|
|
2535
|
+
const selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
|
|
2536
|
+
if (selected === undefined)
|
|
2537
|
+
return;
|
|
2538
|
+
const selectedName = selected.name ?? selected.id;
|
|
2539
|
+
const hasWork = this.agent.session.events.some(event => event.type === 'turn/start');
|
|
2540
|
+
if (!hasWork) {
|
|
2541
|
+
await agentPresets.recompose(this.agent.ctx, selected.id);
|
|
2542
|
+
this.presetId = selected.id;
|
|
2543
|
+
this.presetName = selectedName;
|
|
2544
|
+
this.pushRow({ kind: 'system', text: `已切换到模式:${selectedName}(当前会话生效)。` });
|
|
2545
|
+
}
|
|
2546
|
+
else {
|
|
2547
|
+
this.pushRow({
|
|
2548
|
+
kind: 'system',
|
|
2549
|
+
text: `当前会话已有内容,无法中途切换模式;已记住 ${selectedName},下次启动生效。`,
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
await this.ctx.get('settings')?.update(settingsNamespace('agent-presets'), { default: selected.id });
|
|
2553
|
+
this.markDirty();
|
|
2554
|
+
}
|
|
2555
|
+
/** /resume: switch to a past session, or open a picker when no id is given. */
|
|
2556
|
+
async runResumeCommand(arg, fromLaunch = false) {
|
|
2557
|
+
const target = arg.trim();
|
|
2558
|
+
if (!fromLaunch && this.agent.status === 'running') {
|
|
2559
|
+
this.pushRow({ kind: 'error', text: '当前轮次运行中,请等待结束或按 Esc 取消后再切换会话。' });
|
|
2560
|
+
this.markDirty();
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
if (target !== '') {
|
|
2564
|
+
if (target === String(this.agent.id)) {
|
|
2565
|
+
this.pushRow({ kind: 'system', text: '已在当前会话。' });
|
|
2566
|
+
this.markDirty();
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
this.pushRow({ kind: 'system', text: `正在切换到会话 ${target}…` });
|
|
2570
|
+
this.markDirty();
|
|
2571
|
+
await this.onSwitchSession?.(target);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
2575
|
+
if (persistence === undefined) {
|
|
2576
|
+
this.pushRow({ kind: 'error', text: 'sessionPersistence 服务不可用。' });
|
|
2577
|
+
this.markDirty();
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
const inspected = await listResumableSessions(persistence, String(this.agent.id));
|
|
2581
|
+
if (inspected.length === 0) {
|
|
2582
|
+
this.pushRow({ kind: 'system', text: '没有可恢复的历史会话(也可以直接 /resume <session-id>)。' });
|
|
2583
|
+
this.markDirty();
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
const answer = await this.askQuestion({
|
|
2587
|
+
id: 'resume-pick',
|
|
2588
|
+
question: '选择要恢复的历史会话',
|
|
2589
|
+
options: inspected.map(item => ({
|
|
2590
|
+
label: item.label,
|
|
2591
|
+
description: `${formatSessionTime(item.updatedAt)} · ${item.cwd}`,
|
|
2592
|
+
})),
|
|
2593
|
+
});
|
|
2594
|
+
const picked = inspected.find(item => item.label === answer.selected[0]);
|
|
2595
|
+
if (picked === undefined)
|
|
2596
|
+
return;
|
|
2597
|
+
this.pushRow({ kind: 'system', text: `正在切换到会话 ${picked.id}…` });
|
|
2598
|
+
this.markDirty();
|
|
2599
|
+
await this.onSwitchSession?.(picked.id);
|
|
2600
|
+
}
|
|
2601
|
+
/** Current provider route selected for the running agent. */
|
|
2602
|
+
currentProvider() {
|
|
2603
|
+
// `agent.options` is authoritative for the launched agent; the selection
|
|
2604
|
+
// ref can still hold the persisted default when a CLI override is active.
|
|
2605
|
+
return this.agent.options.provider ?? this.selectionRef?.current?.provider ?? this.providerName;
|
|
2606
|
+
}
|
|
2607
|
+
/** Resolve one credential reference without exposing its value. */
|
|
2608
|
+
async resolveCredential(envRef) {
|
|
2609
|
+
const env = process.env[envRef];
|
|
2610
|
+
if (env !== undefined && env.trim() !== '')
|
|
2611
|
+
return env.trim();
|
|
2612
|
+
const credentials = this.ctx.get('credentials');
|
|
2613
|
+
if (credentials === undefined)
|
|
2614
|
+
return undefined;
|
|
2615
|
+
const resolved = await credentials.resolve(credentialRef(envRef));
|
|
2616
|
+
return resolved?.value.trim() === '' ? undefined : resolved?.value.trim();
|
|
2617
|
+
}
|
|
2618
|
+
/** Query the OpenCode Go quota endpoint. */
|
|
2619
|
+
async fetchOpenCodeGoUsage(apiKey) {
|
|
2620
|
+
let response;
|
|
2621
|
+
try {
|
|
2622
|
+
response = await fetch(OPENCODE_GO_USAGE_URL, {
|
|
2623
|
+
headers: {
|
|
2624
|
+
authorization: `Bearer ${apiKey}`,
|
|
2625
|
+
accept: 'application/json',
|
|
2626
|
+
},
|
|
2627
|
+
signal: AbortSignal.timeout(15_000),
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
catch (error) {
|
|
2631
|
+
throw new Error(`无法访问 OpenCode 额度接口:${errorChain(error)}`);
|
|
2632
|
+
}
|
|
2633
|
+
let payload;
|
|
2634
|
+
try {
|
|
2635
|
+
payload = await response.json();
|
|
2636
|
+
}
|
|
2637
|
+
catch {
|
|
2638
|
+
payload = undefined;
|
|
2639
|
+
}
|
|
2640
|
+
if (!response.ok) {
|
|
2641
|
+
const message = openCodeApiErrorMessage(payload);
|
|
2642
|
+
if (response.status === 401) {
|
|
2643
|
+
throw new Error(`OpenCode Go API Key 无效或未授权(401)${message === '' ? '' : `:${message}`}`);
|
|
2644
|
+
}
|
|
2645
|
+
if (response.status === 403) {
|
|
2646
|
+
throw new Error(`当前 Key 未订阅 OpenCode Go,或额度服务不可用(403)${message === '' ? '' : `:${message}`}`);
|
|
2647
|
+
}
|
|
2648
|
+
throw new Error(`OpenCode Go 额度接口返回 HTTP ${response.status}${message === '' ? '' : `:${message}`}`);
|
|
2649
|
+
}
|
|
2650
|
+
return payload;
|
|
2651
|
+
}
|
|
2652
|
+
/** Explain Zen metered billing instead of pretending it has a quota. */
|
|
2653
|
+
zenUsageText(source) {
|
|
2654
|
+
const usage = this.stats.usage;
|
|
2655
|
+
const billedInput = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
2656
|
+
const tokenLine = billedInput > 0 || usage.outputTokens > 0
|
|
2657
|
+
? `本会话已记录 token:输入 ${formatTokens(billedInput)} · 输出 ${formatTokens(usage.outputTokens)}(会话统计,非账单金额)`
|
|
2658
|
+
: '本会话尚无 token 用量记录。';
|
|
2659
|
+
return [
|
|
2660
|
+
`OpenCode Zen 按量计费(${source.provider})`,
|
|
2661
|
+
'Zen 没有固定额度:请求按 API 账单计费,余额与账单请前往 https://opencode.ai/zen 查看。',
|
|
2662
|
+
tokenLine,
|
|
2663
|
+
].join('\n');
|
|
2664
|
+
}
|
|
2665
|
+
/** /usage and /quota: show Zen billing info or live Go quota usage. */
|
|
2666
|
+
async runUsageCommand() {
|
|
2667
|
+
const provider = this.currentProvider();
|
|
2668
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
2669
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
2670
|
+
if (source === null) {
|
|
2671
|
+
this.pushRow({
|
|
2672
|
+
kind: 'error',
|
|
2673
|
+
text: `当前提供商 ${provider} 不是 OpenCode 源;/usage 仅对 OpenCode Zen/Go 可用。`,
|
|
2674
|
+
});
|
|
2675
|
+
this.markDirty();
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
if (source.flavor === 'zen') {
|
|
2679
|
+
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
2680
|
+
this.markDirty();
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
const apiKey = await this.resolveCredential(source.apiKeyEnv);
|
|
2684
|
+
if (apiKey === undefined) {
|
|
2685
|
+
this.pushRow({
|
|
2686
|
+
kind: 'error',
|
|
2687
|
+
text: `未找到 OpenCode Go 凭据 ${source.apiKeyEnv};请先运行 /setup 配置,或导出该环境变量。`,
|
|
2688
|
+
});
|
|
2689
|
+
this.markDirty();
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
const previousStatus = this.status;
|
|
2693
|
+
this.status = `querying ${source.provider} usage…`;
|
|
2694
|
+
this.markDirty();
|
|
2695
|
+
try {
|
|
2696
|
+
const payload = await this.fetchOpenCodeGoUsage(apiKey);
|
|
2697
|
+
this.pushRow({ kind: 'system', text: formatOpenCodeGoUsage(payload, source) });
|
|
2698
|
+
}
|
|
2699
|
+
finally {
|
|
2700
|
+
this.status = previousStatus;
|
|
2701
|
+
this.markDirty();
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
// ── keyboard ────────────────────────────────────────────────────────────
|
|
2705
|
+
handleData = (chunk) => {
|
|
2706
|
+
const text = this.decoder.write(chunk);
|
|
2707
|
+
const combined = this.escapeBuffer + text;
|
|
2708
|
+
this.escapeBuffer = '';
|
|
2709
|
+
if (this.escapeTimer !== undefined) {
|
|
2710
|
+
clearTimeout(this.escapeTimer);
|
|
2711
|
+
this.escapeTimer = undefined;
|
|
2712
|
+
}
|
|
2713
|
+
const escape = /^\x1b\[([A-D])$/u;
|
|
2714
|
+
const match = combined.match(escape);
|
|
2715
|
+
if (match !== null) {
|
|
2716
|
+
switch (match[1]) {
|
|
2717
|
+
case 'A':
|
|
2718
|
+
if (this.suggestionsVisible()) {
|
|
2719
|
+
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
2720
|
+
this.markDirty();
|
|
2721
|
+
}
|
|
2722
|
+
else if (this.input === '' && this.collapsibleRows().length > 0) {
|
|
2723
|
+
this.moveCollapsibleFocus(-1);
|
|
2724
|
+
}
|
|
2725
|
+
else {
|
|
2726
|
+
this.historyBack();
|
|
2727
|
+
}
|
|
2728
|
+
return;
|
|
2729
|
+
case 'B':
|
|
2730
|
+
if (this.suggestionsVisible()) {
|
|
2731
|
+
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
2732
|
+
this.markDirty();
|
|
2733
|
+
}
|
|
2734
|
+
else if (this.input === '' && this.collapsibleRows().length > 0) {
|
|
2735
|
+
this.moveCollapsibleFocus(1);
|
|
2736
|
+
}
|
|
2737
|
+
else {
|
|
2738
|
+
this.historyForward();
|
|
2739
|
+
}
|
|
2740
|
+
return;
|
|
2741
|
+
case 'C':
|
|
2742
|
+
this.moveCursor(1);
|
|
2743
|
+
return;
|
|
2744
|
+
case 'D':
|
|
2745
|
+
this.moveCursor(-1);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
const sgrMouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u.exec(combined);
|
|
2750
|
+
if (sgrMouse !== null) {
|
|
2751
|
+
const button = Number(sgrMouse[1]);
|
|
2752
|
+
const y = Number(sgrMouse[3]);
|
|
2753
|
+
if (sgrMouse[4] === 'M') {
|
|
2754
|
+
if (button === 64) {
|
|
2755
|
+
this.scrollOffset += 3;
|
|
2756
|
+
this.markDirty();
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
if (button === 65) {
|
|
2760
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 3);
|
|
2761
|
+
this.markDirty();
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
if (button === 0) {
|
|
2765
|
+
this.handleMouseClick(y);
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (combined === '\x1b[5~') {
|
|
2772
|
+
this.scrollOffset += Math.max(3, Math.floor((process.stdout.rows || 24) / 2));
|
|
2773
|
+
this.markDirty();
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
if (combined === '\x1b[6~') {
|
|
2777
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
|
|
2778
|
+
this.markDirty();
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
if (combined === '\x1b[H' || combined === '\x1b[1~') {
|
|
2782
|
+
this.cursor = 0;
|
|
2783
|
+
this.markDirty();
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
if (combined === '\x1b[F' || combined === '\x1b[4~') {
|
|
2787
|
+
this.cursor = this.input.length;
|
|
2788
|
+
this.markDirty();
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
if (combined === '\x1b[3~') {
|
|
2792
|
+
this.deleteAtCursor();
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (isEscapePrefix(combined)) {
|
|
2796
|
+
this.escapeBuffer = combined;
|
|
2797
|
+
this.escapeTimer = setTimeout(() => {
|
|
2798
|
+
this.escapeTimer = undefined;
|
|
2799
|
+
const pending = this.escapeBuffer;
|
|
2800
|
+
this.escapeBuffer = '';
|
|
2801
|
+
if (pending === '\x1b') {
|
|
2802
|
+
this.handleChar('\x1b');
|
|
2803
|
+
}
|
|
2804
|
+
else if (pending.startsWith('\x1b[')) {
|
|
2805
|
+
// An escape sequence that never completed: consume it silently
|
|
2806
|
+
// instead of treating its ESC byte as a cancel.
|
|
2807
|
+
}
|
|
2808
|
+
else if (pending !== '') {
|
|
2809
|
+
this.handlePlainText(pending);
|
|
2810
|
+
}
|
|
2811
|
+
}, 60);
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (combined.startsWith('\x1b[')) {
|
|
2815
|
+
// Unknown escape sequence — consume without side effects.
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
if (combined.startsWith('\x1b')) {
|
|
2819
|
+
this.handleChar('\x1b');
|
|
2820
|
+
const rest = combined.slice(1);
|
|
2821
|
+
if (rest !== '')
|
|
2822
|
+
this.handlePlainText(rest);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
this.handlePlainText(combined);
|
|
2826
|
+
};
|
|
2827
|
+
handlePlainText(text) {
|
|
2828
|
+
let previous = '';
|
|
2829
|
+
for (const char of text) {
|
|
2830
|
+
// Windows terminals may deliver Enter as CRLF; consume only the first half.
|
|
2831
|
+
if (char === '\n' && previous === '\r') {
|
|
2832
|
+
previous = char;
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (char === '\r' && previous === '\n') {
|
|
2836
|
+
previous = char;
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
this.handleChar(char);
|
|
2840
|
+
previous = char;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
handleChar(char) {
|
|
2844
|
+
switch (char) {
|
|
2845
|
+
case '\x1b':
|
|
2846
|
+
this.handleEscape();
|
|
2847
|
+
return;
|
|
2848
|
+
case '\r':
|
|
2849
|
+
case '\n':
|
|
2850
|
+
this.submit();
|
|
2851
|
+
return;
|
|
2852
|
+
case '\x7f':
|
|
2853
|
+
this.backspace();
|
|
2854
|
+
return;
|
|
2855
|
+
case '\x08':
|
|
2856
|
+
this.backspace();
|
|
2857
|
+
return;
|
|
2858
|
+
case '\x03':
|
|
2859
|
+
this.handleCtrlC();
|
|
2860
|
+
return;
|
|
2861
|
+
case '\x04':
|
|
2862
|
+
void this.requestExit(0);
|
|
2863
|
+
return;
|
|
2864
|
+
case '\x0c':
|
|
2865
|
+
this.dirty = true;
|
|
2866
|
+
this.render();
|
|
2867
|
+
return;
|
|
2868
|
+
case '\x01':
|
|
2869
|
+
this.cursor = 0;
|
|
2870
|
+
this.markDirty();
|
|
2871
|
+
return;
|
|
2872
|
+
case '\x05':
|
|
2873
|
+
this.cursor = this.input.length;
|
|
2874
|
+
this.markDirty();
|
|
2875
|
+
return;
|
|
2876
|
+
case '\x15':
|
|
2877
|
+
this.input = '';
|
|
2878
|
+
this.cursor = 0;
|
|
2879
|
+
this.inputFolded = false;
|
|
2880
|
+
this.markDirty();
|
|
2881
|
+
return;
|
|
2882
|
+
case '\x0b':
|
|
2883
|
+
this.input = this.input.slice(0, this.cursor);
|
|
2884
|
+
this.markDirty();
|
|
2885
|
+
return;
|
|
2886
|
+
case '\x0e':
|
|
2887
|
+
this.moveCollapsibleFocus(1);
|
|
2888
|
+
return;
|
|
2889
|
+
case '\x10':
|
|
2890
|
+
this.moveCollapsibleFocus(-1);
|
|
2891
|
+
return;
|
|
2892
|
+
case '\x12':
|
|
2893
|
+
this.toggleAllCollapsible();
|
|
2894
|
+
return;
|
|
2895
|
+
case '\x14':
|
|
2896
|
+
this.inputFolded = !this.inputFolded;
|
|
2897
|
+
this.markDirty();
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
if (this.dialog !== undefined) {
|
|
2901
|
+
this.handleDialogChar(char);
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (char === '\t') {
|
|
2905
|
+
if (this.suggestionsVisible()) {
|
|
2906
|
+
const selected = this.commandSuggestions[this.suggestionIndex];
|
|
2907
|
+
if (selected !== undefined) {
|
|
2908
|
+
this.input = `/${selected.name} `;
|
|
2909
|
+
this.cursor = this.input.length;
|
|
2910
|
+
this.commandSuggestions = [];
|
|
2911
|
+
this.suggestionIndex = 0;
|
|
2912
|
+
this.markDirty();
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
this.input = `${this.input.slice(0, this.cursor)} ${this.input.slice(this.cursor)}`;
|
|
2917
|
+
this.cursor += 2;
|
|
2918
|
+
this.markDirty();
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (char >= ' ' && char !== '\x7f') {
|
|
2922
|
+
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
2923
|
+
this.cursor += char.length;
|
|
2924
|
+
this.markDirty();
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
handleDialogChar(text) {
|
|
2928
|
+
const dialog = this.dialog;
|
|
2929
|
+
if (dialog === undefined)
|
|
2930
|
+
return;
|
|
2931
|
+
if (dialog.kind === 'onboarding') {
|
|
2932
|
+
this.handleOnboardingChar(text);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (dialog.kind === 'confirm') {
|
|
2936
|
+
if (text === 'y' || text === 'Y')
|
|
2937
|
+
this.closeConfirm('y');
|
|
2938
|
+
else if (text === 'n' || text === 'N')
|
|
2939
|
+
this.closeConfirm('n');
|
|
2940
|
+
else if (text === '\x03' || text === '\x1b')
|
|
2941
|
+
this.closeConfirm('cancel');
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
const digit = /^[0-9]$/u.exec(text)?.[0];
|
|
2945
|
+
if (digit !== undefined) {
|
|
2946
|
+
const index = Number(digit) - 1;
|
|
2947
|
+
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
2948
|
+
if (dialog.question.multiSelect === true) {
|
|
2949
|
+
if (dialog.selected.has(index))
|
|
2950
|
+
dialog.selected.delete(index);
|
|
2951
|
+
else
|
|
2952
|
+
dialog.selected.add(index);
|
|
2953
|
+
}
|
|
2954
|
+
else {
|
|
2955
|
+
dialog.selected.clear();
|
|
2956
|
+
dialog.selected.add(index);
|
|
2957
|
+
}
|
|
2958
|
+
this.markDirty();
|
|
2959
|
+
}
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
if (text === '\r' || text === '\n') {
|
|
2963
|
+
const options = dialog.question.options ?? [];
|
|
2964
|
+
const selected = [...dialog.selected].map(index => options[index]?.label).filter((label) => label !== undefined);
|
|
2965
|
+
if (selected.length === 0 && options.length > 0 && dialog.question.multiSelect !== true) {
|
|
2966
|
+
// no selection: treat as cancel unless there are no options
|
|
2967
|
+
dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
if (options.length === 0) {
|
|
2971
|
+
dialog.resolve({ selected: [], custom: this.input });
|
|
2972
|
+
this.input = '';
|
|
2973
|
+
this.cursor = 0;
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
dialog.resolve({ selected });
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
if (text === '\x1b' || text === '\x03') {
|
|
2980
|
+
dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
if (optionsLength(dialog) === 0) {
|
|
2984
|
+
for (const char of text) {
|
|
2985
|
+
if (char >= ' ' && char !== '\x7f') {
|
|
2986
|
+
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
2987
|
+
this.cursor += char.length;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
this.markDirty();
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
handleOnboardingChar(text) {
|
|
2994
|
+
const state = this.onboarding;
|
|
2995
|
+
if (state === undefined)
|
|
2996
|
+
return;
|
|
2997
|
+
switch (state.step) {
|
|
2998
|
+
case 'provider':
|
|
2999
|
+
{
|
|
3000
|
+
const selected = text === '1' ? 'official'
|
|
3001
|
+
: text === '2' ? 'opencode-go'
|
|
3002
|
+
: text === '3' ? 'openai-completions'
|
|
3003
|
+
: text === '4' ? 'openai-responses'
|
|
3004
|
+
: text === '5' ? 'anthropic-messages'
|
|
3005
|
+
: undefined;
|
|
3006
|
+
if (selected !== undefined) {
|
|
3007
|
+
state.providerType = selected;
|
|
3008
|
+
state.providerId = '';
|
|
3009
|
+
state.baseUrl = '';
|
|
3010
|
+
state.key = '';
|
|
3011
|
+
state.models = [];
|
|
3012
|
+
this.input = '';
|
|
3013
|
+
this.cursor = 0;
|
|
3014
|
+
this.advanceOnboarding();
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
return;
|
|
3018
|
+
case 'id':
|
|
3019
|
+
case 'base-url':
|
|
3020
|
+
case 'key':
|
|
3021
|
+
case 'models': {
|
|
3022
|
+
if (state.step === 'models' && text === '\x06') {
|
|
3023
|
+
void this.fetchOnboardingModels();
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
if (text === '\r' || text === '\n') {
|
|
3027
|
+
const value = this.input.trim();
|
|
3028
|
+
if (state.step === 'id') {
|
|
3029
|
+
const template = PROVIDER_TEMPLATES[state.providerType];
|
|
3030
|
+
const id = value === '' ? template.defaultId : value;
|
|
3031
|
+
if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
|
|
3032
|
+
this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
|
|
3033
|
+
this.markDirty();
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
state.providerId = id;
|
|
3037
|
+
}
|
|
3038
|
+
else if (state.step === 'key') {
|
|
3039
|
+
if (value === '') {
|
|
3040
|
+
this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
|
|
3041
|
+
this.markDirty();
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
state.key = value;
|
|
3045
|
+
}
|
|
3046
|
+
else if (state.step === 'models') {
|
|
3047
|
+
const template = PROVIDER_TEMPLATES[state.providerType];
|
|
3048
|
+
const parsed = value === ''
|
|
3049
|
+
? template.defaultModels
|
|
3050
|
+
: value.split(/[\s,,]+/u).filter(Boolean);
|
|
3051
|
+
if (parsed.length === 0) {
|
|
3052
|
+
this.pushRow({ kind: 'error', text: '至少需要一个模型 ID。' });
|
|
3053
|
+
this.markDirty();
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
state.models = parsed;
|
|
3057
|
+
}
|
|
3058
|
+
else {
|
|
3059
|
+
state.baseUrl = value;
|
|
3060
|
+
}
|
|
3061
|
+
this.input = '';
|
|
3062
|
+
this.cursor = 0;
|
|
3063
|
+
this.advanceOnboarding();
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
for (const char of text) {
|
|
3067
|
+
if (char >= ' ' && char !== '\x7f') {
|
|
3068
|
+
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
3069
|
+
this.cursor += char.length;
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
this.markDirty();
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
case 'confirm':
|
|
3076
|
+
if (text === 'y' || text === 'Y') {
|
|
3077
|
+
this.dialog = undefined;
|
|
3078
|
+
this.input = '';
|
|
3079
|
+
this.cursor = 0;
|
|
3080
|
+
void this.saveOnboarding();
|
|
3081
|
+
}
|
|
3082
|
+
else if (text === 'n' || text === 'N') {
|
|
3083
|
+
state.step = 'provider';
|
|
3084
|
+
state.providerType = 'official';
|
|
3085
|
+
state.providerId = '';
|
|
3086
|
+
state.baseUrl = '';
|
|
3087
|
+
state.key = '';
|
|
3088
|
+
state.models = [];
|
|
3089
|
+
this.input = '';
|
|
3090
|
+
this.cursor = 0;
|
|
3091
|
+
this.markDirty();
|
|
3092
|
+
}
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
advanceOnboarding() {
|
|
3097
|
+
const state = this.onboarding;
|
|
3098
|
+
if (state === undefined)
|
|
3099
|
+
return;
|
|
3100
|
+
if (state.step === 'provider') {
|
|
3101
|
+
state.step = state.providerType === 'official' ? 'key' : 'id';
|
|
3102
|
+
}
|
|
3103
|
+
else if (state.step === 'id') {
|
|
3104
|
+
state.step = 'base-url';
|
|
3105
|
+
}
|
|
3106
|
+
else if (state.step === 'base-url') {
|
|
3107
|
+
state.step = 'key';
|
|
3108
|
+
}
|
|
3109
|
+
else if (state.step === 'key') {
|
|
3110
|
+
state.step = 'models';
|
|
3111
|
+
}
|
|
3112
|
+
else if (state.step === 'models') {
|
|
3113
|
+
state.step = 'confirm';
|
|
3114
|
+
}
|
|
3115
|
+
this.input = '';
|
|
3116
|
+
this.cursor = 0;
|
|
3117
|
+
this.markDirty();
|
|
3118
|
+
}
|
|
3119
|
+
/** Fetch the endpoint's model list into the onboarding wizard's models step. */
|
|
3120
|
+
async fetchOnboardingModels() {
|
|
3121
|
+
const state = this.onboarding;
|
|
3122
|
+
if (state === undefined || state.step !== 'models')
|
|
3123
|
+
return;
|
|
3124
|
+
const template = PROVIDER_TEMPLATES[state.providerType];
|
|
3125
|
+
const providerType = state.providerType;
|
|
3126
|
+
const baseUrl = state.baseUrl;
|
|
3127
|
+
const key = state.key;
|
|
3128
|
+
const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
|
|
3129
|
+
if (baseURL === '') {
|
|
3130
|
+
this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
|
|
3131
|
+
this.markDirty();
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
const previousStatus = this.status;
|
|
3135
|
+
this.status = '正在从端点获取模型列表…';
|
|
3136
|
+
this.markDirty();
|
|
3137
|
+
try {
|
|
3138
|
+
const llm = this.ctx.get('llm');
|
|
3139
|
+
if (llm === undefined)
|
|
3140
|
+
throw new Error('llm 服务不可用');
|
|
3141
|
+
const discovered = await llm.discoverModels(settingsNamespace('llm-pi-ai'), {
|
|
3142
|
+
baseURL,
|
|
3143
|
+
...(template.api === undefined ? {} : { api: template.api }),
|
|
3144
|
+
...(key === '' ? {} : { apiKey: key }),
|
|
3145
|
+
signal: AbortSignal.timeout(15_000),
|
|
3146
|
+
});
|
|
3147
|
+
// Apply only if the wizard is still on the same draft the fetch started
|
|
3148
|
+
// from, so a stale reply cannot overwrite a newer edit or a reset.
|
|
3149
|
+
const stillCurrent = this.onboarding === state
|
|
3150
|
+
&& state.step === 'models'
|
|
3151
|
+
&& state.providerType === providerType
|
|
3152
|
+
&& state.baseUrl === baseUrl
|
|
3153
|
+
&& state.key === key;
|
|
3154
|
+
if (!stillCurrent)
|
|
3155
|
+
return;
|
|
3156
|
+
const ids = [...new Set(discovered.map(model => model.id).filter(id => id.length > 0))];
|
|
3157
|
+
if (ids.length === 0) {
|
|
3158
|
+
this.pushRow({ kind: 'error', text: '端点没有返回可用模型,请手动输入模型 ID。' });
|
|
3159
|
+
}
|
|
3160
|
+
else {
|
|
3161
|
+
state.models = ids;
|
|
3162
|
+
this.input = '';
|
|
3163
|
+
this.cursor = 0;
|
|
3164
|
+
this.pushRow({ kind: 'system', text: `已从端点获取 ${ids.length} 个模型(Enter 确认,也可继续修改)。` });
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
catch (error) {
|
|
3168
|
+
this.pushRow({ kind: 'error', text: `获取模型列表失败:${errorChain(error)}` });
|
|
3169
|
+
}
|
|
3170
|
+
finally {
|
|
3171
|
+
this.status = previousStatus;
|
|
3172
|
+
this.markDirty();
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
async saveOnboarding() {
|
|
3176
|
+
const state = this.onboarding;
|
|
3177
|
+
if (state === undefined)
|
|
3178
|
+
return;
|
|
3179
|
+
let saved = true;
|
|
3180
|
+
try {
|
|
3181
|
+
const credentials = this.ctx.get('credentials');
|
|
3182
|
+
const settings = this.ctx.get('settings');
|
|
3183
|
+
const template = PROVIDER_TEMPLATES[state.providerType];
|
|
3184
|
+
if (state.providerType === 'official') {
|
|
3185
|
+
const envRef = 'DEEPSEEK_API_KEY';
|
|
3186
|
+
await this.saveCredential(credentials, envRef, state.key);
|
|
3187
|
+
const model = state.models[0] ?? 'deepseek-v4-pro';
|
|
3188
|
+
await this.ctx.get('agentDefaultModel')?.saveSelection({ provider: 'deepseek-official', model });
|
|
3189
|
+
if (this.selectionRef !== undefined) {
|
|
3190
|
+
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
3191
|
+
}
|
|
3192
|
+
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
3193
|
+
if (state.baseUrl !== '' && settings !== undefined) {
|
|
3194
|
+
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
3195
|
+
this.pushRow({ kind: 'system', text: `Base URL 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
3196
|
+
}
|
|
3197
|
+
if (saved) {
|
|
3198
|
+
this.pushRow({
|
|
3199
|
+
kind: 'system',
|
|
3200
|
+
text: `配置完成,已记住默认提供商/模型:deepseek-official / ${model}。以后直接运行 dsh --profile tui 即可。`,
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
else {
|
|
3205
|
+
const envRef = envRefForId(state.providerId);
|
|
3206
|
+
const model = state.models[0];
|
|
3207
|
+
// OpenCode / third-party (llm-pi-ai) routes have no adapter-level
|
|
3208
|
+
// reasoning default. Re-running setup must not silently drop the
|
|
3209
|
+
// effort that makes thinking arrive as `reasoning` blocks; default it
|
|
3210
|
+
// to a supported level (if any) and persist it in both the profile
|
|
3211
|
+
// and the default-model selection.
|
|
3212
|
+
const llm = this.ctx.get('llm');
|
|
3213
|
+
const defaultEffort = model !== undefined && llm !== undefined
|
|
3214
|
+
? await defaultReasoningEffort(llm, state.providerId, model)
|
|
3215
|
+
: undefined;
|
|
3216
|
+
const profile = {
|
|
3217
|
+
displayName: template.label,
|
|
3218
|
+
apiKeyEnv: envRef,
|
|
3219
|
+
api: template.api,
|
|
3220
|
+
baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
|
|
3221
|
+
models: state.models.map(id => ({ id })),
|
|
3222
|
+
...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
|
|
3223
|
+
};
|
|
3224
|
+
if (settings === undefined) {
|
|
3225
|
+
this.pushRow({ kind: 'error', text: '设置服务不可用,自定义提供商未保存。' });
|
|
3226
|
+
saved = false;
|
|
3227
|
+
}
|
|
3228
|
+
else {
|
|
3229
|
+
await settings.mutate(settingsNamespace('llm-pi-ai'), [
|
|
3230
|
+
{ op: 'set', path: ['providers', state.providerId], value: profile },
|
|
3231
|
+
]);
|
|
3232
|
+
this.pushRow({ kind: 'system', text: `提供商 ${state.providerId} 已保存 → ${displayDshPath('settings.yaml')}` });
|
|
3233
|
+
}
|
|
3234
|
+
await this.saveCredential(credentials, envRef, state.key);
|
|
3235
|
+
if (saved) {
|
|
3236
|
+
const selection = {
|
|
3237
|
+
provider: state.providerId,
|
|
3238
|
+
model,
|
|
3239
|
+
...(defaultEffort === undefined ? {} : { reasoningEffort: defaultEffort }),
|
|
3240
|
+
};
|
|
3241
|
+
await this.ctx.get('agentDefaultModel')?.saveSelection(selection);
|
|
3242
|
+
if (this.selectionRef !== undefined) {
|
|
3243
|
+
this.selectionRef.current = selection;
|
|
3244
|
+
}
|
|
3245
|
+
this.onSelectionChanged?.(selection);
|
|
3246
|
+
this.pushRow({
|
|
3247
|
+
kind: 'system',
|
|
3248
|
+
text: `配置完成,已记住默认提供商/模型:${state.providerId} / ${model}。以后直接运行 dsh --profile tui 即可(--provider/--model 可临时覆盖)。`,
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
catch (error) {
|
|
3254
|
+
saved = false;
|
|
3255
|
+
this.pushRow({ kind: 'error', text: `保存配置失败: ${errorChain(error)}` });
|
|
3256
|
+
}
|
|
3257
|
+
finally {
|
|
3258
|
+
this.onboarding = undefined;
|
|
3259
|
+
state.resolve(saved);
|
|
3260
|
+
this.markDirty();
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
/** Store one credential, falling back to a launch-environment override on shadow/absence. */
|
|
3264
|
+
async saveCredential(credentials, envRef, key) {
|
|
3265
|
+
const shadowing = process.env[envRef];
|
|
3266
|
+
const shadowed = shadowing !== undefined && shadowing !== '';
|
|
3267
|
+
if (credentials !== undefined && !shadowed) {
|
|
3268
|
+
await credentials.set(credentialRef(envRef), key);
|
|
3269
|
+
this.pushRow({ kind: 'system', text: `${envRef} 已保存 → ${displayDshPath('.credentials.yaml')}` });
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
await this.writeLaunchEnv({ [envRef]: key });
|
|
3273
|
+
this.pushRow({
|
|
3274
|
+
kind: 'system',
|
|
3275
|
+
text: shadowed
|
|
3276
|
+
? IS_WINDOWS
|
|
3277
|
+
? `环境变量 ${envRef} 已存在且优先,已用 setx + env.cmd 覆盖;新开的终端生效。`
|
|
3278
|
+
: `环境变量 ${envRef} 已存在且优先,已写入启动环境覆盖;新开的终端生效。`
|
|
3279
|
+
: `凭据服务不可用,已写入启动环境覆盖 → ${displayDshPath(IS_WINDOWS ? 'env.cmd' : 'env.sh')}`,
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
/** Write launch-environment overrides so they beat system-injected variables. */
|
|
3283
|
+
async writeLaunchEnv(entries) {
|
|
3284
|
+
const home = dshHomeDir();
|
|
3285
|
+
const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
|
|
3286
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
3287
|
+
if (IS_WINDOWS) {
|
|
3288
|
+
const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.'];
|
|
3289
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
3290
|
+
lines.push(`set "${name}=${value.replaceAll('"', '')}"`);
|
|
3291
|
+
}
|
|
3292
|
+
await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
|
|
3293
|
+
// Persist for future processes; best-effort, env.cmd remains as a manual fallback.
|
|
3294
|
+
await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3298
|
+
const lines = ['# Generated by dsh-ssh-tui onboarding.'];
|
|
3299
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
3300
|
+
lines.push(`export ${name}=${quote(value)}`);
|
|
3301
|
+
}
|
|
3302
|
+
await writeFile(file, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
3303
|
+
await this.ensurePosixEnvHook();
|
|
3304
|
+
}
|
|
3305
|
+
/** Persist one variable into the Windows user environment (best-effort). */
|
|
3306
|
+
setWindowsEnv(name, value) {
|
|
3307
|
+
return new Promise((resolve) => {
|
|
3308
|
+
const child = spawn('setx', [name, value], { stdio: 'ignore', windowsHide: true });
|
|
3309
|
+
child.on('error', () => resolve());
|
|
3310
|
+
child.on('exit', () => resolve());
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
/** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
|
|
3314
|
+
async ensurePosixEnvHook() {
|
|
3315
|
+
const sourceLine = `[ -f "$HOME/.dsh/env.sh" ] && . "$HOME/.dsh/env.sh"`;
|
|
3316
|
+
const marker = '# dsh-ssh-tui launch environment';
|
|
3317
|
+
const shell = process.env.SHELL ?? '';
|
|
3318
|
+
const targets = [];
|
|
3319
|
+
if (shell.endsWith('zsh'))
|
|
3320
|
+
targets.push('.zshenv', '.zshrc');
|
|
3321
|
+
else if (shell.endsWith('fish'))
|
|
3322
|
+
targets.push('.config/fish/config.fish');
|
|
3323
|
+
else
|
|
3324
|
+
targets.push('.bashrc');
|
|
3325
|
+
targets.push('.profile');
|
|
3326
|
+
for (const relative of targets) {
|
|
3327
|
+
const file = join(homedir(), relative);
|
|
3328
|
+
let content = '';
|
|
3329
|
+
try {
|
|
3330
|
+
content = await readFile(file, 'utf8');
|
|
3331
|
+
}
|
|
3332
|
+
catch {
|
|
3333
|
+
// File absent: create it below when it is a primary target.
|
|
3334
|
+
}
|
|
3335
|
+
if (content.includes(marker))
|
|
3336
|
+
continue;
|
|
3337
|
+
const line = relative.endsWith('config.fish')
|
|
3338
|
+
? 'test -f "$HOME/.dsh/env.sh"; and source "$HOME/.dsh/env.sh"'
|
|
3339
|
+
: sourceLine;
|
|
3340
|
+
const addition = `${content === '' ? '' : '\n'}${marker}\n${line}\n`;
|
|
3341
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
3342
|
+
await writeFile(file, content + addition, { mode: 0o600 });
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
handleEscape() {
|
|
3346
|
+
if (this.dialog !== undefined) {
|
|
3347
|
+
if (this.dialog.kind === 'confirm')
|
|
3348
|
+
this.closeConfirm('cancel');
|
|
3349
|
+
else if (this.dialog.kind === 'onboarding')
|
|
3350
|
+
this.cancelOnboarding();
|
|
3351
|
+
else
|
|
3352
|
+
this.dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
if (this.scrollOffset > 0) {
|
|
3356
|
+
this.scrollOffset = 0;
|
|
3357
|
+
this.markDirty();
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
if (this.focusedRow !== null) {
|
|
3361
|
+
this.focusedRow = null;
|
|
3362
|
+
this.markDirty();
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
if (this.suggestionsVisible()) {
|
|
3366
|
+
this.commandSuggestions = [];
|
|
3367
|
+
this.suggestionIndex = 0;
|
|
3368
|
+
this.markDirty();
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
if (this.agent.status === 'running') {
|
|
3372
|
+
this.pushRow({ kind: 'system', text: '已请求取消当前轮次…' });
|
|
3373
|
+
this.agent.cancel({ kind: 'user' });
|
|
3374
|
+
this.status = 'cancelling…';
|
|
3375
|
+
this.markDirty();
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
/** Toggle the collapsible row under a click on the transcript area. */
|
|
3379
|
+
handleMouseClick(y) {
|
|
3380
|
+
if (this.dialog !== undefined)
|
|
3381
|
+
return;
|
|
3382
|
+
const row = this.clickableRows.get(y);
|
|
3383
|
+
if (row === undefined)
|
|
3384
|
+
return;
|
|
3385
|
+
this.focusedRow = row;
|
|
3386
|
+
row.expanded = !row.expanded;
|
|
3387
|
+
this.markDirty();
|
|
3388
|
+
}
|
|
3389
|
+
handleCtrlC() {
|
|
3390
|
+
if (this.dialog !== undefined) {
|
|
3391
|
+
this.handleEscape();
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
if (this.agent.status === 'running') {
|
|
3395
|
+
this.pushRow({ kind: 'system', text: '已请求取消当前轮次…(Ctrl+C)' });
|
|
3396
|
+
this.agent.cancel({ kind: 'user' });
|
|
3397
|
+
this.status = 'cancelling…';
|
|
3398
|
+
this.markDirty();
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
void this.requestExit(130);
|
|
3402
|
+
}
|
|
3403
|
+
submit() {
|
|
3404
|
+
if (this.dialog !== undefined) {
|
|
3405
|
+
this.handleDialogChar('\r');
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
this.scrollOffset = 0;
|
|
3409
|
+
if (this.input.trim() === '' && this.collapsibleRows().length > 0) {
|
|
3410
|
+
this.toggleCollapsible();
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
if (this.suggestionsVisible()) {
|
|
3414
|
+
const selected = this.commandSuggestions[this.suggestionIndex];
|
|
3415
|
+
if (selected !== undefined && selected.name.startsWith(this.input.slice(1))) {
|
|
3416
|
+
this.input = `/${selected.name}`;
|
|
3417
|
+
this.cursor = this.input.length;
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
const text = this.input.trim();
|
|
3421
|
+
if (text === '')
|
|
3422
|
+
return;
|
|
3423
|
+
if (text.startsWith('/')) {
|
|
3424
|
+
this.runCommand(text);
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
if (this.agentGone)
|
|
3428
|
+
return;
|
|
3429
|
+
this.history.push(text);
|
|
3430
|
+
this.historyIndex = this.history.length;
|
|
3431
|
+
this.input = '';
|
|
3432
|
+
this.cursor = 0;
|
|
3433
|
+
this.inputFolded = false;
|
|
3434
|
+
const message = createUserMessage({
|
|
3435
|
+
content: [{ type: 'text', text }],
|
|
3436
|
+
source: { kind: 'user' },
|
|
3437
|
+
});
|
|
3438
|
+
if (this.agent.status === 'running') {
|
|
3439
|
+
this.pendingMessages.set(message.id, text);
|
|
3440
|
+
this.pushRow({ kind: 'system', text: `⚡ ${text}(运行中已提交,将在下个步骤生效;Esc/Ctrl+C 可中断)` });
|
|
3441
|
+
this.agent.steer(message);
|
|
3442
|
+
}
|
|
3443
|
+
else {
|
|
3444
|
+
this.agent.followup(message);
|
|
3445
|
+
}
|
|
3446
|
+
this.markDirty();
|
|
3447
|
+
}
|
|
3448
|
+
runCommand(text) {
|
|
3449
|
+
const [command, ...rest] = text.slice(1).split(/\s+/u);
|
|
3450
|
+
const arg = rest.join(' ');
|
|
3451
|
+
switch (command) {
|
|
3452
|
+
case 'help': {
|
|
3453
|
+
const local = LOCAL_COMMANDS
|
|
3454
|
+
.filter(item => item.name !== 'help' && item.name !== 'exit')
|
|
3455
|
+
.map(item => `/${item.name.padEnd(12)} ${item.description}`);
|
|
3456
|
+
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
3457
|
+
.map(item => `/${item.name.padEnd(12)} ${item.description} (dsh)`);
|
|
3458
|
+
this.pushRow({
|
|
3459
|
+
kind: 'system',
|
|
3460
|
+
text: [
|
|
3461
|
+
...local,
|
|
3462
|
+
...dsh,
|
|
3463
|
+
'',
|
|
3464
|
+
'Enter while running steers the agent; Esc or Ctrl+C cancels the turn.',
|
|
3465
|
+
].join('\n'),
|
|
3466
|
+
});
|
|
3467
|
+
break;
|
|
3468
|
+
}
|
|
3469
|
+
case 'quit':
|
|
3470
|
+
case 'exit':
|
|
3471
|
+
void this.requestExit(0);
|
|
3472
|
+
break;
|
|
3473
|
+
case 'model':
|
|
3474
|
+
void this.runModelCommand().catch((error) => {
|
|
3475
|
+
if (error instanceof UserQuestionError) {
|
|
3476
|
+
this.pushRow({ kind: 'system', text: '模型选择已取消。' });
|
|
3477
|
+
}
|
|
3478
|
+
else {
|
|
3479
|
+
this.pushRow({ kind: 'error', text: `/model failed: ${errorChain(error)}` });
|
|
3480
|
+
}
|
|
3481
|
+
this.markDirty();
|
|
3482
|
+
});
|
|
3483
|
+
break;
|
|
3484
|
+
case 'mode':
|
|
3485
|
+
void this.runModeCommand().catch((error) => {
|
|
3486
|
+
if (error instanceof UserQuestionError) {
|
|
3487
|
+
this.pushRow({ kind: 'system', text: '模式选择已取消。' });
|
|
3488
|
+
}
|
|
3489
|
+
else {
|
|
3490
|
+
this.pushRow({ kind: 'error', text: `/mode failed: ${errorChain(error)}` });
|
|
3491
|
+
}
|
|
3492
|
+
this.markDirty();
|
|
3493
|
+
});
|
|
3494
|
+
break;
|
|
3495
|
+
case 'clear':
|
|
3496
|
+
this.rows.length = 0;
|
|
3497
|
+
this.streaming = undefined;
|
|
3498
|
+
break;
|
|
3499
|
+
case 'status':
|
|
3500
|
+
this.pushRow({
|
|
3501
|
+
kind: 'system',
|
|
3502
|
+
text: `session: ${this.agent.id}\nmodel: ${this.agent.options.model ?? 'default'}\nprovider: ${this.agent.options.provider ?? 'default'}\nstatus: ${this.agent.status}`,
|
|
3503
|
+
});
|
|
3504
|
+
break;
|
|
3505
|
+
case 'usage':
|
|
3506
|
+
case 'quota':
|
|
3507
|
+
void this.runUsageCommand().catch((error) => {
|
|
3508
|
+
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
3509
|
+
this.markDirty();
|
|
3510
|
+
});
|
|
3511
|
+
break;
|
|
3512
|
+
case 'subagents': {
|
|
3513
|
+
if (this.activeSubagents.size === 0) {
|
|
3514
|
+
this.pushRow({ kind: 'system', text: '当前没有活动的子代理。' });
|
|
3515
|
+
}
|
|
3516
|
+
else {
|
|
3517
|
+
const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => `▶ ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]`);
|
|
3518
|
+
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
3519
|
+
}
|
|
3520
|
+
break;
|
|
3521
|
+
}
|
|
3522
|
+
case 'resume':
|
|
3523
|
+
void this.runResumeCommand(arg).catch((error) => {
|
|
3524
|
+
if (error instanceof UserQuestionError) {
|
|
3525
|
+
this.pushRow({ kind: 'system', text: '会话选择已取消。' });
|
|
3526
|
+
}
|
|
3527
|
+
else {
|
|
3528
|
+
this.pushRow({ kind: 'error', text: `/resume failed: ${errorChain(error)}` });
|
|
3529
|
+
}
|
|
3530
|
+
this.markDirty();
|
|
3531
|
+
});
|
|
3532
|
+
break;
|
|
3533
|
+
case 'setup':
|
|
3534
|
+
void this.runOnboarding();
|
|
3535
|
+
break;
|
|
3536
|
+
case 'dialog-test': {
|
|
3537
|
+
const questions = this.ctx.get('userQuestions');
|
|
3538
|
+
if (questions === undefined) {
|
|
3539
|
+
this.pushRow({ kind: 'error', text: 'userQuestions service is unavailable' });
|
|
3540
|
+
break;
|
|
3541
|
+
}
|
|
3542
|
+
void questions.ask({
|
|
3543
|
+
questions: [{
|
|
3544
|
+
id: 'tui-test',
|
|
3545
|
+
question: 'Choose an option to verify the dialog',
|
|
3546
|
+
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
|
3547
|
+
}],
|
|
3548
|
+
agent: this.agent,
|
|
3549
|
+
}).then((answer) => this.pushRow({ kind: 'system', text: `dialog answer: ${JSON.stringify(answer)}` }), (error) => this.pushRow({ kind: 'error', text: `dialog error: ${errorChain(error)}` }));
|
|
3550
|
+
break;
|
|
3551
|
+
}
|
|
3552
|
+
default:
|
|
3553
|
+
{
|
|
3554
|
+
const commands = this.ctx.get('commands');
|
|
3555
|
+
if (commands === undefined) {
|
|
3556
|
+
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
3557
|
+
break;
|
|
3558
|
+
}
|
|
3559
|
+
const controller = new AbortController();
|
|
3560
|
+
void commands.execute(this.agent, text, controller.signal).then((execution) => {
|
|
3561
|
+
if (execution === undefined) {
|
|
3562
|
+
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
const result = execution.result;
|
|
3566
|
+
if (result.kind === 'success') {
|
|
3567
|
+
if (result.text !== undefined && result.text !== '') {
|
|
3568
|
+
this.pushRow({ kind: 'system', text: result.text });
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
else {
|
|
3572
|
+
this.pushRow({ kind: 'error', text: result.text });
|
|
3573
|
+
}
|
|
3574
|
+
}).catch((error) => {
|
|
3575
|
+
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
break;
|
|
3579
|
+
}
|
|
3580
|
+
this.input = '';
|
|
3581
|
+
this.cursor = 0;
|
|
3582
|
+
this.inputFolded = false;
|
|
3583
|
+
this.markDirty();
|
|
3584
|
+
}
|
|
3585
|
+
backspace() {
|
|
3586
|
+
if (this.cursor === 0)
|
|
3587
|
+
return;
|
|
3588
|
+
this.input = `${this.input.slice(0, this.cursor - 1)}${this.input.slice(this.cursor)}`;
|
|
3589
|
+
this.cursor -= 1;
|
|
3590
|
+
this.markDirty();
|
|
3591
|
+
}
|
|
3592
|
+
deleteAtCursor() {
|
|
3593
|
+
if (this.cursor >= this.input.length)
|
|
3594
|
+
return;
|
|
3595
|
+
this.input = `${this.input.slice(0, this.cursor)}${this.input.slice(this.cursor + 1)}`;
|
|
3596
|
+
this.markDirty();
|
|
3597
|
+
}
|
|
3598
|
+
moveCursor(delta) {
|
|
3599
|
+
this.cursor = Math.max(0, Math.min(this.input.length, this.cursor + delta));
|
|
3600
|
+
this.markDirty();
|
|
3601
|
+
}
|
|
3602
|
+
historyBack() {
|
|
3603
|
+
if (this.history.length === 0)
|
|
3604
|
+
return;
|
|
3605
|
+
if (this.historyIndex <= 0)
|
|
3606
|
+
return;
|
|
3607
|
+
this.historyIndex -= 1;
|
|
3608
|
+
this.input = this.history[this.historyIndex] ?? '';
|
|
3609
|
+
this.cursor = this.input.length;
|
|
3610
|
+
this.markDirty();
|
|
3611
|
+
}
|
|
3612
|
+
historyForward() {
|
|
3613
|
+
if (this.historyIndex >= this.history.length)
|
|
3614
|
+
return;
|
|
3615
|
+
this.historyIndex += 1;
|
|
3616
|
+
this.input = this.history[this.historyIndex] ?? '';
|
|
3617
|
+
this.cursor = this.input.length;
|
|
3618
|
+
this.markDirty();
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
function optionsLength(dialog) {
|
|
3622
|
+
return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
|
|
3623
|
+
}
|
|
3624
|
+
function envRefForId(providerId) {
|
|
3625
|
+
return `${providerId.replaceAll('-', '_').toUpperCase()}_API_KEY`;
|
|
3626
|
+
}
|
|
3627
|
+
function collectText(blocks) {
|
|
3628
|
+
const parts = [];
|
|
3629
|
+
for (const block of blocks) {
|
|
3630
|
+
if (block.type === 'text' && block.text !== undefined)
|
|
3631
|
+
parts.push(block.text);
|
|
3632
|
+
else if (block.type === 'tool-result' && block.content !== undefined)
|
|
3633
|
+
parts.push(collectText(block.content));
|
|
3634
|
+
}
|
|
3635
|
+
return parts.join('\n');
|
|
3636
|
+
}
|
|
3637
|
+
/**
|
|
3638
|
+
* Mount the terminal channel once the configured agent exists.
|
|
3639
|
+
*
|
|
3640
|
+
* @param ctx - context supplying the agent registry, sessions, and event stream.
|
|
3641
|
+
* @param config - target agent and presentation config.
|
|
3642
|
+
* @returns lifecycle controller used by the Cordis effect disposer.
|
|
3643
|
+
*/
|
|
3644
|
+
export function mountTui(ctx, config) {
|
|
3645
|
+
const sessionId = SessionId(config.sessionId);
|
|
3646
|
+
let settled = false;
|
|
3647
|
+
let controller;
|
|
3648
|
+
const start = (agent) => {
|
|
3649
|
+
if (settled || agent.id !== sessionId || !ctx.agents.roots().includes(agent))
|
|
3650
|
+
return;
|
|
3651
|
+
settled = true;
|
|
3652
|
+
stopWaiting();
|
|
3653
|
+
controller = new SshTui(ctx, agent, config);
|
|
3654
|
+
controller.start();
|
|
3655
|
+
controller.replayHistory();
|
|
3656
|
+
};
|
|
3657
|
+
const fail = (failedSessionId, error) => {
|
|
3658
|
+
if (settled || failedSessionId !== sessionId)
|
|
3659
|
+
return;
|
|
3660
|
+
settled = true;
|
|
3661
|
+
stopWaiting();
|
|
3662
|
+
process.stdout.write(`dsh-ssh-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`);
|
|
3663
|
+
const exit = ctx.get('appExit');
|
|
3664
|
+
if (exit !== undefined)
|
|
3665
|
+
exit(1);
|
|
3666
|
+
else
|
|
3667
|
+
process.exit(1);
|
|
3668
|
+
};
|
|
3669
|
+
const disposeCreated = ctx.on('agent/created', ({ agent }) => start(agent));
|
|
3670
|
+
const disposeFailure = ctx.on('agent-loop/config-start-failed', ({ sessionId: failedSessionId, error }) => fail(failedSessionId, error));
|
|
3671
|
+
const stopWaiting = () => {
|
|
3672
|
+
disposeCreated();
|
|
3673
|
+
disposeFailure();
|
|
3674
|
+
};
|
|
3675
|
+
const existing = ctx.agents.roots().find(agent => agent.id === sessionId);
|
|
3676
|
+
if (existing !== undefined)
|
|
3677
|
+
start(existing);
|
|
3678
|
+
return {
|
|
3679
|
+
async dispose() {
|
|
3680
|
+
stopWaiting();
|
|
3681
|
+
await controller?.dispose();
|
|
3682
|
+
},
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
//# sourceMappingURL=tui.js.map
|