dsh-ssh-tui 0.1.5 → 0.1.7

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