dsh-ssh-tui 0.1.4 → 0.1.6

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