dsh-ssh-tui 0.5.10 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.en.md +27 -4
  2. package/README.md +71 -6
  3. package/lib/attach.js +156 -0
  4. package/lib/attach.js.map +1 -0
  5. package/lib/commands.js +73 -0
  6. package/lib/commands.js.map +1 -0
  7. package/lib/diag.js +292 -0
  8. package/lib/diag.js.map +1 -0
  9. package/lib/dialogs.js +82 -0
  10. package/lib/dialogs.js.map +1 -0
  11. package/lib/display-sock.js +276 -96
  12. package/lib/display-sock.js.map +1 -1
  13. package/lib/dsh-compat.js +25 -4
  14. package/lib/dsh-compat.js.map +1 -1
  15. package/lib/i18n/en.js +55 -0
  16. package/lib/i18n/en.js.map +1 -1
  17. package/lib/i18n/index.js +2 -0
  18. package/lib/i18n/index.js.map +1 -1
  19. package/lib/i18n/zh.js +55 -0
  20. package/lib/i18n/zh.js.map +1 -1
  21. package/lib/index.js +75 -85
  22. package/lib/index.js.map +1 -1
  23. package/lib/paint.js +20 -56
  24. package/lib/paint.js.map +1 -1
  25. package/lib/picker.js +152 -37
  26. package/lib/picker.js.map +1 -1
  27. package/lib/rows.js +120 -0
  28. package/lib/rows.js.map +1 -0
  29. package/lib/session-index.js +4 -1
  30. package/lib/session-index.js.map +1 -1
  31. package/lib/session-list.js +372 -167
  32. package/lib/session-list.js.map +1 -1
  33. package/lib/session-lock.js +20 -9
  34. package/lib/session-lock.js.map +1 -1
  35. package/lib/stats.js +136 -0
  36. package/lib/stats.js.map +1 -0
  37. package/lib/terminal-input.js +470 -0
  38. package/lib/terminal-input.js.map +1 -0
  39. package/lib/tui.js +252 -311
  40. package/lib/tui.js.map +1 -1
  41. package/lib/types/attach.d.ts +106 -0
  42. package/lib/types/commands.d.ts +103 -0
  43. package/lib/types/diag.d.ts +78 -0
  44. package/lib/types/dialogs.d.ts +79 -0
  45. package/lib/types/display-sock.d.ts +49 -3
  46. package/lib/types/dsh-compat.d.ts +7 -0
  47. package/lib/types/i18n/index.d.ts +4 -0
  48. package/lib/types/index.d.ts +8 -4
  49. package/lib/types/paint.d.ts +6 -5
  50. package/lib/types/picker.d.ts +27 -7
  51. package/lib/types/rows.d.ts +69 -0
  52. package/lib/types/session-list.d.ts +43 -3
  53. package/lib/types/stats.d.ts +98 -0
  54. package/lib/types/terminal-input.d.ts +164 -0
  55. package/lib/types/tui.d.ts +33 -8
  56. package/package.json +11 -5
package/lib/tui.js CHANGED
@@ -30,7 +30,12 @@ import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusAr
30
30
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
31
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
32
32
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
33
- import { detachFromSshSession, DisplayHost, resolveDshHome, sessionSockPath } from './display-sock.js';
33
+ import { collectDiag, formatDiag } from './diag.js';
34
+ import { SessionStatsTracker, statsRowOf } from './stats.js';
35
+ import { QUESTION_OPTION_KEYS, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
36
+ import { commandSuggestions, localizedCommands } from './commands.js';
37
+ import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
38
+ import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
34
39
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
35
40
  import { defaultReasoningEffort } from './reasoning.js';
36
41
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
@@ -42,10 +47,11 @@ import { copyTextFromTranscript } from './copy-text.js';
42
47
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
43
48
  import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
44
49
  import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
50
+ import { TerminalInputGuard } from './terminal-input.js';
45
51
  import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
46
52
  import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
47
53
  import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
48
- import { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, TOOL_FLIP_MS, wrappedToolBodyLineCount, } from './tool-present.js';
54
+ import { buildToolHeader, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
49
55
  export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
50
56
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
51
57
  export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
@@ -138,16 +144,50 @@ const PLUGIN_VERSION = (() => {
138
144
  })();
139
145
  const STALL_WARNING_MS = 60000;
140
146
  const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
147
+ /**
148
+ * How long a leftover Host that has finished its work waits, with no display,
149
+ * for the user to come back before it exits on its own.
150
+ *
151
+ * The Host holds the session's kernel write lock (`session.lock`) for its whole
152
+ * life, and that lock is exactly what makes the browser surface answer
153
+ * `resume failed for session "…"` (the persistence layer refuses a second
154
+ * writer). A Host kept alive by a busy SSH drop therefore used to block the same
155
+ * session in the Web UI for the full {@link DEFAULT_DETACHED_IDLE_MS} (six
156
+ * hours), long after the turn it stayed behind for had already finished: the
157
+ * session looked unreachable from both surfaces while an idle process held it.
158
+ * A minute is enough to notice the drop and reattach; after that the turn has
159
+ * long been flushed, so `--resume` reopens the same log in a fresh Host.
160
+ *
161
+ * `DSH_TUI_IDLE_EXIT_MS` (or `ssh-tui.idleExit` in settings.yaml) overrides it;
162
+ * `0`/`off` restores the legacy "wait for the six-hour timer" behavior.
163
+ */
164
+ const DEFAULT_IDLE_EXIT_MS = 60 * 1000;
141
165
  const CTRL_C_EXIT_WINDOW_MS = 2000;
142
166
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
143
- const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
144
167
  const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
145
168
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
146
- const MAX_TRANSCRIPT_ROWS = 5000;
147
169
  const IS_WINDOWS = process.platform === 'win32';
148
170
  function dshHomeDir() {
149
171
  return resolveDshHome();
150
172
  }
173
+ /**
174
+ * Version of the host packages this process booted from, for `/diag`. Resolved
175
+ * from the running plugin's own tree, so it reports what is actually loaded
176
+ * rather than what is installed elsewhere.
177
+ */
178
+ function hostDshVersion() {
179
+ for (const id of ['@deepseek-ai/dsh-agent/package.json', '@deepseek-ai/dsh/package.json']) {
180
+ try {
181
+ const parsed = createRequire(import.meta.url)(id);
182
+ if (typeof parsed.version === 'string')
183
+ return parsed.version;
184
+ }
185
+ catch {
186
+ // try the next candidate
187
+ }
188
+ }
189
+ return 'unknown';
190
+ }
151
191
  function displayDshPath(file) {
152
192
  const home = dshHomeDir();
153
193
  if (IS_WINDOWS) {
@@ -271,45 +311,6 @@ const DEEPSEEK_LOGO_VARIANTS = [
271
311
  ],
272
312
  },
273
313
  ];
274
- const LOCAL_COMMANDS = [
275
- { name: 'help', key: 'cmd.help' },
276
- { name: 'model', key: 'cmd.model' },
277
- { name: 'effort', key: 'cmd.effort' },
278
- { name: 'provider', key: 'cmd.provider' },
279
- { name: 'submodel', key: 'cmd.submodel' },
280
- { name: 'subeffort', key: 'cmd.subeffort' },
281
- { name: 'mode', key: 'cmd.mode' },
282
- { name: 'quit', key: 'cmd.quit' },
283
- { name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
284
- { name: 'clear', key: 'cmd.clear' },
285
- { name: 'status', key: 'cmd.status' },
286
- { name: 'disconnect', key: 'cmd.disconnect' },
287
- { name: 'approval', key: 'cmd.approval' },
288
- { name: 'view', key: 'cmd.view' },
289
- { name: 'usage', key: 'cmd.usage' },
290
- { name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
291
- { name: 'quota', key: 'cmd.usage', aliasOf: 'usage' },
292
- { name: 'subagents', key: 'cmd.subagents' },
293
- { name: 'resume', key: 'cmd.resume' },
294
- { name: 'setup', key: 'cmd.setup' },
295
- { name: 'find', key: 'cmd.find' },
296
- { name: 'copy', key: 'cmd.copy' },
297
- { name: 'language', key: 'cmd.language' },
298
- { name: 'lang', key: 'cmd.language', aliasOf: 'language' },
299
- { name: 'dialog-test', key: 'cmd.dialog-test' },
300
- ];
301
- function commandDescription(name, aliasOf) {
302
- if (aliasOf !== undefined)
303
- return t('cmd.aliasOf', { name: aliasOf });
304
- return t(`cmd.${name}`);
305
- }
306
- function localizedCommands() {
307
- return LOCAL_COMMANDS.map(command => ({
308
- name: command.name,
309
- description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
310
- ...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
311
- }));
312
- }
313
314
  export function parseDisconnectPolicy(raw) {
314
315
  const id = raw.trim().toLowerCase();
315
316
  if (id === 'pause' || id === 'cancel' || id === '暂停')
@@ -392,6 +393,9 @@ export class SshTui {
392
393
  headlessDisplay;
393
394
  disconnectPolicy;
394
395
  detachedIdleTimer;
396
+ /** Armed once an SSH drop left this Host alive with work still running. */
397
+ hostKeptAlive = false;
398
+ idleExitTimer;
395
399
  displayDetached = false;
396
400
  displayHost;
397
401
  relayColumns;
@@ -434,27 +438,18 @@ export class SshTui {
434
438
  openToolCalls = new Map();
435
439
  /** Survives result settlement so a card-less result can still be labelled. */
436
440
  toolCallNames = new Map();
437
- stats = {
438
- turns: 0,
439
- steps: 0,
440
- llmMs: 0,
441
- toolMs: 0,
442
- ttftMs: 0,
443
- ttftSteps: 0,
444
- decodeMs: 0,
445
- decodeTokens: 0,
446
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
447
- };
448
- openStepStats;
441
+ /** Session totals; the tracker owns the arithmetic (see `stats.ts`). */
442
+ statsTracker = new SessionStatsTracker();
443
+ /** Snapshot of the session totals, for the footer and `/status`. */
444
+ get stats() {
445
+ return this.statsTracker.snapshot();
446
+ }
449
447
  /** Attempt whose live `start` frame opened the current token stream. */
450
448
  liveStreamOwner;
451
449
  /** Live events parked while the (yielding) history replay holds the floor. */
452
450
  replayQueue;
453
451
  /** A relay claimed the display while a hangup was still cancelling/flushing. */
454
452
  reattachedDuringHangup = false;
455
- pendingToolTimes = new Map();
456
- usageByStep = new Map();
457
- lastStatsTurn = null;
458
453
  scrollOffset = 0;
459
454
  clickableRows = new Map();
460
455
  paintedLinkHitsByRow = new Map();
@@ -470,6 +465,12 @@ export class SshTui {
470
465
  streamingReasoning;
471
466
  escapeBuffer = '';
472
467
  escapeTimer;
468
+ /**
469
+ * Cursor-position replies removed from the relay's stdin stream. A launcher
470
+ * from an older release (or a reply that raced its own probe) would otherwise
471
+ * type `[17;1R` into the prompt or cancel a dialog with a bare ESC.
472
+ */
473
+ inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
473
474
  thinkingStartedAt;
474
475
  waitStartedAt;
475
476
  completionSignaled = false;
@@ -742,6 +743,62 @@ export class SshTui {
742
743
  }, idleMs);
743
744
  this.detachedIdleTimer.unref?.();
744
745
  }
746
+ /**
747
+ * How long a leftover, finished Host may sit with no display before it exits
748
+ * and hands the session back. `0` disables the exit (legacy behavior).
749
+ */
750
+ idleExitMs() {
751
+ const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
752
+ if (Number.isFinite(raw) && raw >= 0)
753
+ return raw;
754
+ const saved = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
755
+ if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
756
+ const value = saved.idleExit;
757
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
758
+ return value;
759
+ const text = String(value ?? '').trim().toLowerCase();
760
+ if (text === 'off' || text === 'never' || text === 'false')
761
+ return 0;
762
+ const parsed = Number.parseInt(text, 10);
763
+ if (Number.isFinite(parsed) && parsed >= 0)
764
+ return parsed;
765
+ }
766
+ return DEFAULT_IDLE_EXIT_MS;
767
+ }
768
+ clearIdleExitTimer() {
769
+ if (this.idleExitTimer !== undefined)
770
+ clearTimeout(this.idleExitTimer);
771
+ this.idleExitTimer = undefined;
772
+ }
773
+ /**
774
+ * Arm the exit for a Host a busy SSH drop left behind. Called when the agent
775
+ * goes idle — the reason the Host was kept is gone, and every second it stays
776
+ * is a second its `session.lock` keeps the browser surface from opening the
777
+ * session (`resume failed for session "…"`). A reattach cancels it.
778
+ */
779
+ armIdleExitTimer() {
780
+ if (!this.headlessDisplay || !this.hostKeptAlive)
781
+ return;
782
+ const idleMs = this.idleExitMs();
783
+ if (idleMs <= 0)
784
+ return;
785
+ this.clearIdleExitTimer();
786
+ this.idleExitTimer = setTimeout(() => {
787
+ this.idleExitTimer = undefined;
788
+ if (this.disposed || this.exiting)
789
+ return;
790
+ if (this.displayHost?.attached === true)
791
+ return;
792
+ if (this.isBusyForHangupKeepalive()) {
793
+ this.armIdleExitTimer();
794
+ return;
795
+ }
796
+ // Flush + exit, exactly like an idle hangup: the turn already ended, so
797
+ // nothing is cancelled and the lock goes back to the session store.
798
+ void this.requestExit(0);
799
+ }, idleMs);
800
+ this.idleExitTimer.unref?.();
801
+ }
745
802
  isCompactView() {
746
803
  return this.workspaceView === 'compact';
747
804
  }
@@ -1030,6 +1087,7 @@ export class SshTui {
1030
1087
  if (this.escapeTimer !== undefined)
1031
1088
  clearTimeout(this.escapeTimer);
1032
1089
  this.escapeTimer = undefined;
1090
+ this.inputGuard.stop();
1033
1091
  process.stdin.removeListener('data', this.handleData);
1034
1092
  process.stdout.removeListener('resize', this.onDirectResize);
1035
1093
  process.stdout.removeListener('error', this.handleIoError);
@@ -1075,6 +1133,7 @@ export class SshTui {
1075
1133
  this.disposed = true;
1076
1134
  this.exiting = true;
1077
1135
  this.clearDetachedIdleTimer();
1136
+ this.clearIdleExitTimer();
1078
1137
  const dialog = this.dialog;
1079
1138
  const queued = this.dialogQueue.splice(0);
1080
1139
  this.dialog = undefined;
@@ -1110,6 +1169,27 @@ export class SshTui {
1110
1169
  if (host !== undefined)
1111
1170
  await host.close();
1112
1171
  }
1172
+ /**
1173
+ * `/diag`: collect the local facts about this session's channel, lock, and
1174
+ * Host, then print the decision chain. Everything is local; nothing is sent
1175
+ * anywhere (see the privacy note in the README).
1176
+ */
1177
+ async runDiagCommand() {
1178
+ const snapshot = await collectDiag({
1179
+ sessionId: String(this.agent.id),
1180
+ pluginVersion: PLUGIN_VERSION,
1181
+ hostVersion: hostDshVersion(),
1182
+ hostProcess: isTuiHostProcess(),
1183
+ link: {
1184
+ kind: this.paintLink === 'ssh' ? 'ssh' : 'local',
1185
+ ...(this.paintRttMs === undefined ? {} : { rttMs: this.paintRttMs }),
1186
+ probeState: this.paintProbed ? 'measured' : this.paintLink === 'ssh' ? 'unknown' : 'unprobed',
1187
+ },
1188
+ ...(this.paintIntervalMs === undefined ? {} : { paintIntervalMs: this.paintIntervalMs }),
1189
+ });
1190
+ this.pushRow({ kind: 'system', text: formatDiag(snapshot).join('\n') });
1191
+ this.markDirty();
1192
+ }
1113
1193
  /** Human-facing exit with goodbye and flush; called from key handling. */
1114
1194
  async requestExit(code) {
1115
1195
  if (this.hangingUp)
@@ -1199,6 +1279,12 @@ export class SshTui {
1199
1279
  const keepHost = this.displayHost !== undefined && busy;
1200
1280
  if (keepHost) {
1201
1281
  this.hangingUp = false;
1282
+ // A relay that arrived while this hangup was unwinding set
1283
+ // `reattachedDuringHangup` so the hangup would honor it. That relay
1284
+ // *cancels* this hangup when it HELLOs, so reaching this keep-host path
1285
+ // means the flag is already cleared above; clearing it here too would be
1286
+ // dead code, and the status handler clears it on the next turn.
1287
+ this.hostKeptAlive = true;
1202
1288
  this.armDetachedIdleTimer();
1203
1289
  await this.onHangup?.();
1204
1290
  return;
@@ -1366,7 +1452,11 @@ export class SshTui {
1366
1452
  this.reattachedDuringHangup = true;
1367
1453
  this.displayDetached = false;
1368
1454
  this.hangingUp = false;
1455
+ this.hostKeptAlive = false;
1369
1456
  this.clearDetachedIdleTimer();
1457
+ // The user is back: the leftover Host is a live session again, so the
1458
+ // idle exit must not fire out from under the display that just attached.
1459
+ this.clearIdleExitTimer();
1370
1460
  this.lastActivity = Date.now();
1371
1461
  this.stalledWarningShown = false;
1372
1462
  this.lastPaintRows = [];
@@ -1416,52 +1506,27 @@ export class SshTui {
1416
1506
  return parts.join(' · ');
1417
1507
  }
1418
1508
  mergeIntoToolCard(previous, next) {
1419
- const ids = previous.mergedCallIds ?? [previous.callId];
1420
- if (!ids.includes(previous.callId))
1421
- ids.push(previous.callId);
1422
- if (!ids.includes(next.callId))
1423
- ids.push(next.callId);
1424
- previous.mergedCallIds = ids;
1425
- previous.callId = next.callId;
1426
- previous.name = next.name;
1427
- previous.args = next.args;
1428
- if (next.title !== '')
1429
- previous.title = next.title;
1430
- if (next.summary !== '')
1431
- previous.summary = next.summary;
1432
- if (next.diff !== undefined && next.diff.length > 0) {
1433
- previous.diff = (previous.repeats ?? 1) > 1 && previous.diff !== undefined && previous.diff.length > 0
1434
- ? [...previous.diff, ...next.diff]
1435
- : next.diff;
1436
- }
1437
- previous.status = 'running';
1438
- previous.output = '';
1439
- previous.exitCode = undefined;
1440
- previous.signal = undefined;
1441
- previous.repeats = (previous.repeats ?? 1) + 1;
1442
- if (!this.replaying)
1443
- previous.flipUntil = Date.now() + TOOL_FLIP_MS;
1509
+ mergeToolCard(previous, next, Date.now(), this.replaying);
1444
1510
  }
1445
1511
  findToolRowByCallId(callId) {
1446
- return this.rows.findLast((candidate) => candidate.kind === 'tool'
1447
- && (candidate.callId === callId || candidate.mergedCallIds?.includes(callId) === true));
1512
+ return findToolRowByCallId(this.rows, callId);
1448
1513
  }
1449
1514
  findMergeableToolRow(next) {
1450
- const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
1451
- return canMergeToolCall(previous, next) ? previous : undefined;
1515
+ return findMergeableToolRow(this.rows, next);
1452
1516
  }
1453
1517
  /** Append one transcript row, bounding memory on long sessions. */
1454
1518
  pushRow(row) {
1455
1519
  this.rows.push(row);
1456
- if (this.rows.length > MAX_TRANSCRIPT_ROWS) {
1457
- const removed = this.rows.length - MAX_TRANSCRIPT_ROWS;
1458
- if (this.focusedRow !== null
1459
- && this.focusedRow.kind !== 'streaming-reasoning'
1460
- && this.rows.indexOf(this.focusedRow) < removed) {
1461
- this.focusedRow = null;
1462
- }
1463
- this.rows.splice(0, removed);
1464
- }
1520
+ // Locate the focused card before trimming: after the splice every surviving
1521
+ // index shifts down and a stale index would clear the focus by accident.
1522
+ const focusedIndex = this.focusedRow === null || this.focusedRow.kind === 'streaming-reasoning'
1523
+ ? undefined
1524
+ : this.rows.indexOf(this.focusedRow);
1525
+ const removed = boundTranscriptRows(this.rows);
1526
+ if (removed === 0)
1527
+ return;
1528
+ if (focusedIndex !== undefined && focusedIndex < removed)
1529
+ this.focusedRow = null;
1465
1530
  }
1466
1531
  /** The transcript rows that support per-row expand/collapse. */
1467
1532
  collapsibleRows() {
@@ -1533,29 +1598,15 @@ export class SshTui {
1533
1598
  ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
1534
1599
  };
1535
1600
  }
1536
- planShouldDefaultExpand(plan) {
1537
- return plan.active === true
1538
- || plan.pending === true
1539
- || plan.todos.some(item => item.status === 'in_progress');
1540
- }
1541
1601
  findSubagentRow(sessionId) {
1542
1602
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
1543
1603
  }
1544
1604
  findLivePlanRow() {
1545
- return this.rows.findLast((row) => row.kind === 'plan' && planIsLive(row));
1605
+ return findLivePlanRow(this.rows);
1546
1606
  }
1547
1607
  /** Older / finished plans stay in the scrolling transcript. */
1548
1608
  archiveStalePlans(keep) {
1549
- for (const row of this.rows) {
1550
- if (row.kind !== 'plan' || row === keep)
1551
- continue;
1552
- if (row.archived === true)
1553
- continue;
1554
- row.archived = true;
1555
- row.active = false;
1556
- row.pending = false;
1557
- row.expanded = false;
1558
- }
1609
+ archiveStalePlans(this.rows, keep);
1559
1610
  }
1560
1611
  upsertPlanRow(patch) {
1561
1612
  const existing = this.findLivePlanRow();
@@ -1573,7 +1624,7 @@ export class SshTui {
1573
1624
  existing.archived = true;
1574
1625
  existing.expanded = false;
1575
1626
  }
1576
- else if (patch.expanded === undefined && this.planShouldDefaultExpand(existing)) {
1627
+ else if (patch.expanded === undefined && planShouldDefaultExpand(existing)) {
1577
1628
  existing.expanded = true;
1578
1629
  }
1579
1630
  this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
@@ -1585,19 +1636,16 @@ export class SshTui {
1585
1636
  existing.pending = false;
1586
1637
  existing.expanded = false;
1587
1638
  }
1639
+ const active = patch.active ?? false;
1640
+ const pending = patch.pending ?? false;
1641
+ const todos = patch.todos ?? [];
1588
1642
  const row = {
1589
1643
  kind: 'plan',
1590
- active: patch.active ?? false,
1591
- pending: patch.pending ?? false,
1592
- todos: patch.todos ?? [],
1644
+ active,
1645
+ pending,
1646
+ todos,
1593
1647
  ...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
1594
- expanded: this.planShouldDefaultExpand({
1595
- kind: 'plan',
1596
- active: patch.active ?? false,
1597
- pending: patch.pending ?? false,
1598
- todos: patch.todos ?? [],
1599
- expanded: false,
1600
- }),
1648
+ expanded: planShouldDefaultExpand({ active, pending, todos }),
1601
1649
  archived: false,
1602
1650
  };
1603
1651
  this.pushRow(row);
@@ -2538,29 +2586,18 @@ export class SshTui {
2538
2586
  const inputDivider = this.styleLine('system', repeatToWidth('─', width));
2539
2587
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
2540
2588
  const available = Math.max(0, height - reserved - dialogLines.length);
2541
- const maxOffset = Math.max(0, display.length - available);
2542
- const reveal = this.pendingReveal;
2543
- if (reveal !== undefined) {
2544
- this.pendingReveal = undefined;
2545
- const first = displayRefs.findIndex(ref => ref === reveal);
2546
- if (first !== -1) {
2547
- let last = first;
2548
- while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
2549
- last += 1;
2550
- const span = last - first + 1;
2551
- this.scrollOffset = Math.max(0, display.length - available - first);
2552
- }
2553
- }
2554
- if (this.scrollOffset > maxOffset)
2555
- this.scrollOffset = maxOffset;
2556
- const start = Math.max(0, display.length - available - this.scrollOffset);
2557
- const visible = display.slice(start, start + available);
2558
- const visibleRefs = displayRefs.slice(start, start + available);
2559
- const padding = Math.max(0, available - visible.length);
2560
- for (let index = 0; index < padding; index++) {
2561
- visible.unshift('');
2562
- visibleRefs.unshift(undefined);
2563
- }
2589
+ const window = windowTranscript({
2590
+ lines: display,
2591
+ refs: displayRefs,
2592
+ available,
2593
+ scrollOffset: this.scrollOffset,
2594
+ reveal: this.pendingReveal,
2595
+ });
2596
+ this.pendingReveal = undefined;
2597
+ this.scrollOffset = window.scrollOffset;
2598
+ const start = window.start;
2599
+ const visible = window.visibleLines;
2600
+ const visibleRefs = window.visibleRefs;
2564
2601
  this.clickableRows.clear();
2565
2602
  this.paintedLinkHitsByRow.clear();
2566
2603
  for (let index = 0; index < visibleRefs.length; index++) {
@@ -2578,20 +2615,7 @@ export class SshTui {
2578
2615
  this.clickableRows.set(dockTop, dockPlan);
2579
2616
  }
2580
2617
  const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
2581
- const statsGroups = footerStatsGroups({
2582
- turns: this.stats.turns,
2583
- steps: this.stats.steps,
2584
- llmMs: this.stats.llmMs,
2585
- toolMs: this.stats.toolMs,
2586
- ttftMs: this.stats.ttftMs,
2587
- ttftSteps: this.stats.ttftSteps,
2588
- decodeMs: this.stats.decodeMs,
2589
- decodeTokens: this.stats.decodeTokens,
2590
- inputTokens: this.stats.usage.inputTokens,
2591
- outputTokens: this.stats.usage.outputTokens,
2592
- cacheReadTokens: this.stats.usage.cacheReadTokens,
2593
- cacheWriteTokens: this.stats.usage.cacheWriteTokens,
2594
- });
2618
+ const statsGroups = footerStatsGroups(statsRowOf(this.statsTracker.snapshot()));
2595
2619
  const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
2596
2620
  const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
2597
2621
  const statsLine = statsPlain.startsWith(chipVisible)
@@ -2742,19 +2766,15 @@ export class SshTui {
2742
2766
  this.markDirty();
2743
2767
  }
2744
2768
  buildSuggestions() {
2745
- const input = this.input;
2746
- if (!input.startsWith('/'))
2769
+ // Called on every paint: leave the host command service alone unless the
2770
+ // line is actually a slash command.
2771
+ if (!this.input.startsWith('/'))
2747
2772
  return [];
2748
- const prefix = input.slice(1).toLowerCase();
2749
- const local = localizedCommands()
2750
- .filter(command => command.name !== 'dialog-test' && (prefix !== '' || command.aliasOf === undefined))
2751
- .map(command => ({ name: command.name, description: command.description, local: true }));
2752
- const seen = new Set(local.map(command => command.name));
2753
- const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
2754
- .filter(command => !seen.has(command.name))
2773
+ // The host's commands arrive last (a local name wins the duplicate) and are
2774
+ // localized here, where the i18n catalog is already loaded.
2775
+ const foreign = (this.ctx.get('commands')?.list(this.agent) ?? [])
2755
2776
  .map(command => {
2756
- const descKey = `cmd.${command.name}`;
2757
- const desc = t(descKey, undefined, command.description);
2777
+ const desc = t(`cmd.${command.name}`, undefined, command.description);
2758
2778
  return {
2759
2779
  name: command.name,
2760
2780
  description: commandAcceptsAttachments(command.input)
@@ -2763,17 +2783,7 @@ export class SshTui {
2763
2783
  local: false,
2764
2784
  };
2765
2785
  });
2766
- const all = [...local, ...dsh];
2767
- const filtered = prefix === ''
2768
- ? all
2769
- : all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
2770
- if (prefix === '')
2771
- return filtered;
2772
- return filtered.sort((a, b) => {
2773
- const aStart = a.name.startsWith(prefix) ? 0 : 1;
2774
- const bStart = b.name.startsWith(prefix) ? 0 : 1;
2775
- return aStart - bStart;
2776
- });
2786
+ return commandSuggestions(this.input, foreign);
2777
2787
  }
2778
2788
  suggestionsVisible() {
2779
2789
  return this.commandSuggestions.length > 0 && this.dialog === undefined;
@@ -2789,41 +2799,9 @@ export class SshTui {
2789
2799
  const kind = describeProviderRoute(provider).short;
2790
2800
  return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
2791
2801
  }
2792
- /** Replace one step's usage sample so a repeated report never double counts. */
2793
- recordUsage(turn, step, usage) {
2794
- const key = `${turn}:${step}`;
2795
- const next = {
2796
- inputTokens: usage.inputTokens,
2797
- outputTokens: usage.outputTokens,
2798
- cacheReadTokens: usage.cacheReadTokens ?? 0,
2799
- cacheWriteTokens: usage.cacheWriteTokens ?? 0,
2800
- };
2801
- const previous = this.usageByStep.get(key);
2802
- const totals = this.stats.usage;
2803
- this.stats.usage = {
2804
- inputTokens: totals.inputTokens - (previous?.inputTokens ?? 0) + next.inputTokens,
2805
- outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
2806
- cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
2807
- cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
2808
- };
2809
- this.usageByStep.set(key, next);
2810
- }
2811
2802
  /** Compact session stats groups for the first footer row. */
2812
2803
  statsText() {
2813
- return footerStatsGroups({
2814
- turns: this.stats.turns,
2815
- steps: this.stats.steps,
2816
- llmMs: this.stats.llmMs,
2817
- toolMs: this.stats.toolMs,
2818
- ttftMs: this.stats.ttftMs,
2819
- ttftSteps: this.stats.ttftSteps,
2820
- decodeMs: this.stats.decodeMs,
2821
- decodeTokens: this.stats.decodeTokens,
2822
- inputTokens: this.stats.usage.inputTokens,
2823
- outputTokens: this.stats.usage.outputTokens,
2824
- cacheReadTokens: this.stats.usage.cacheReadTokens,
2825
- cacheWriteTokens: this.stats.usage.cacheWriteTokens,
2826
- }).join(' │ ');
2804
+ return footerStatsGroups(statsRowOf(this.statsTracker.snapshot())).join(' │ ');
2827
2805
  }
2828
2806
  /** Refresh the terminal window title (throttled while running). */
2829
2807
  updateTerminalTitle() {
@@ -2924,15 +2902,11 @@ export class SshTui {
2924
2902
  */
2925
2903
  applyStreamChunk(streamed, statsOnly = false) {
2926
2904
  const { chunk } = streamed;
2927
- const open = this.openStepStats;
2928
- if (open !== null && open !== undefined
2929
- && open.turn === streamed.turn && open.step === streamed.step) {
2930
- if (open.firstTokenTime === null && isTokenDeltaChunk(chunk)) {
2931
- this.openStepStats = { ...open, firstTokenTime: streamed.time };
2932
- }
2905
+ if (isTokenDeltaChunk(chunk)) {
2906
+ this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
2933
2907
  }
2934
2908
  if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
2935
- this.recordUsage(streamed.turn, streamed.step, chunk.usage);
2909
+ this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
2936
2910
  }
2937
2911
  if (statsOnly)
2938
2912
  return;
@@ -2982,7 +2956,7 @@ export class SshTui {
2982
2956
  const current = this.liveStreamOwner;
2983
2957
  const fallback = current !== undefined && (attemptId === undefined || attemptId === current.attemptId)
2984
2958
  ? current
2985
- : this.openStepStats;
2959
+ : this.statsTracker.currentStep();
2986
2960
  const streamed = streamChunkOf(payload.frame, fallback);
2987
2961
  if (streamed !== undefined)
2988
2962
  this.applyStreamChunk(streamed);
@@ -3093,29 +3067,19 @@ export class SshTui {
3093
3067
  .filter(block => block.type === 'reasoning')
3094
3068
  .map(block => block.text)
3095
3069
  .join('');
3096
- const open = this.openStepStats;
3097
- if (open !== undefined && open.turn === event.data.turn && open.step === event.data.step) {
3098
- this.stats.llmMs += Math.max(0, event.time - open.startTime);
3099
- // The settlement's own packed stream is authoritative: a retried
3100
- // step keeps the failed attempt's first token in the live latch, and
3101
- // spanning both attempts reported a rate ~20x off. 0.1.2 has no
3102
- // `stream` and still relies on the replayed `assistant/chunk` events
3103
- // (or the live latch) instead.
3104
- const firstTokenTime = streamFirstTokenTime(event.data.stream)
3105
- ?? open.firstTokenTime;
3106
- if (firstTokenTime !== null && firstTokenTime !== undefined && Number.isFinite(firstTokenTime)) {
3107
- this.stats.ttftMs += Math.max(0, firstTokenTime - open.startTime);
3108
- this.stats.ttftSteps += 1;
3109
- const outputTokens = event.data.usage?.outputTokens;
3110
- if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
3111
- this.stats.decodeMs += Math.max(0, event.time - firstTokenTime);
3112
- this.stats.decodeTokens += outputTokens;
3113
- }
3114
- }
3115
- this.openStepStats = undefined;
3116
- }
3070
+ // The settlement's own packed stream is authoritative: a retried step
3071
+ // keeps the failed attempt's first token in the live latch, and spanning
3072
+ // both attempts reported a rate ~20x off. 0.1.2 has no `stream` and still
3073
+ // relies on the replayed `assistant/chunk` events (or the live latch).
3074
+ this.statsTracker.settleMessage({
3075
+ turn: event.data.turn,
3076
+ step: event.data.step,
3077
+ time: event.time,
3078
+ firstTokenTime: streamFirstTokenTime(event.data.stream),
3079
+ outputTokens: event.data.usage?.outputTokens,
3080
+ });
3117
3081
  if (event.data.usage !== undefined) {
3118
- this.recordUsage(event.data.turn, event.data.step, event.data.usage);
3082
+ this.statsTracker.recordUsage(event.data.turn, event.data.step, event.data.usage);
3119
3083
  }
3120
3084
  const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
3121
3085
  const interrupted = event.data.interrupted === true;
@@ -3138,7 +3102,7 @@ export class SshTui {
3138
3102
  case 'tool/call': {
3139
3103
  this.openToolCalls.set(String(event.data.callId), event.data.name);
3140
3104
  this.toolCallNames.set(String(event.data.callId), event.data.name);
3141
- this.pendingToolTimes.set(String(event.data.callId), event.time);
3105
+ this.statsTracker.noteToolStart(String(event.data.callId), event.time);
3142
3106
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
3143
3107
  const present = presentToolCall(event.data.name, event.data.arguments);
3144
3108
  const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
@@ -3181,11 +3145,7 @@ export class SshTui {
3181
3145
  }
3182
3146
  case 'tool/result': {
3183
3147
  this.openToolCalls.delete(String(event.data.message.source.callId));
3184
- const dispatchedAt = this.pendingToolTimes.get(String(event.data.message.source.callId));
3185
- if (dispatchedAt !== undefined) {
3186
- this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
3187
- this.pendingToolTimes.delete(String(event.data.message.source.callId));
3188
- }
3148
+ this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
3189
3149
  const callId = String(event.data.message.source.callId);
3190
3150
  const row = this.findToolRowByCallId(callId);
3191
3151
  const output = collectText(event.data.message.content);
@@ -3245,25 +3205,12 @@ export class SshTui {
3245
3205
  break;
3246
3206
  }
3247
3207
  case 'step/start': {
3248
- this.openStepStats = {
3249
- turn: event.data.turn,
3250
- step: event.data.step,
3251
- startTime: event.time,
3252
- firstTokenTime: null,
3253
- };
3208
+ this.statsTracker.noteStepStart(event.data.turn, event.data.step, event.time);
3254
3209
  this.markDirty();
3255
3210
  break;
3256
3211
  }
3257
3212
  case 'step/end': {
3258
- if (this.lastStatsTurn !== event.data.turn) {
3259
- this.stats.turns += 1;
3260
- this.lastStatsTurn = event.data.turn;
3261
- }
3262
- this.stats.steps += 1;
3263
- this.openStepStats = undefined;
3264
- // Usage accounting is complete for this step; the map only exists to
3265
- // deduplicate repeated usage reports during the step.
3266
- this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
3213
+ this.statsTracker.noteStepEnd(event.data.turn, event.data.step);
3267
3214
  if (!this.replaying) {
3268
3215
  this.quotaStepsSinceRefresh += 1;
3269
3216
  const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
@@ -3295,7 +3242,7 @@ export class SshTui {
3295
3242
  const reason = event.data.reason;
3296
3243
  this.openToolCalls.clear();
3297
3244
  this.toolCallNames.clear();
3298
- this.pendingToolTimes.clear();
3245
+ this.statsTracker.noteTurnEnd();
3299
3246
  this.stalledWarningShown = false;
3300
3247
  this.pendingMessages.clear();
3301
3248
  // Aborted/errored turns may close without an assembled
@@ -3344,6 +3291,9 @@ export class SshTui {
3344
3291
  return;
3345
3292
  this.lastActivity = Date.now();
3346
3293
  if (status === 'running') {
3294
+ // Work is running again, so any reattach that raced a former hangup has
3295
+ // long been honored; keeping the flag would swallow the next drop.
3296
+ this.reattachedDuringHangup = false;
3347
3297
  this.completionSignaled = false;
3348
3298
  this.completedAt = 0;
3349
3299
  if (this.waitStartedAt === undefined)
@@ -3362,6 +3312,14 @@ export class SshTui {
3362
3312
  if (status !== 'running') {
3363
3313
  this.flushPlanCloseNudge();
3364
3314
  this.maybeIdleAutoCompact();
3315
+ // A leftover Host exists only because this turn was still running when
3316
+ // SSH dropped. Now that it settled, give the user a short window to
3317
+ // reattach before exiting and freeing the session's write lock for the
3318
+ // browser surface.
3319
+ this.armIdleExitTimer();
3320
+ }
3321
+ else {
3322
+ this.clearIdleExitTimer();
3365
3323
  }
3366
3324
  this.markDirty();
3367
3325
  };
@@ -5605,7 +5563,12 @@ export class SshTui {
5605
5563
  }
5606
5564
  // ── keyboard ────────────────────────────────────────────────────────────
5607
5565
  handleData = (chunk) => {
5608
- const text = this.decoder.write(chunk);
5566
+ const decoded = this.decoder.write(chunk);
5567
+ if (decoded === '')
5568
+ return;
5569
+ this.inputGuard.push(decoded);
5570
+ };
5571
+ handleInputText(text) {
5609
5572
  const combined = this.escapeBuffer + text;
5610
5573
  this.escapeBuffer = '';
5611
5574
  if (this.escapeTimer !== undefined) {
@@ -5796,7 +5759,7 @@ export class SshTui {
5796
5759
  return;
5797
5760
  }
5798
5761
  this.handlePlainText(combined);
5799
- };
5762
+ }
5800
5763
  /** Handle one data chunk that may contain bracketed-paste markers. */
5801
5764
  processPasteChunk(combined) {
5802
5765
  let index = 0;
@@ -5982,14 +5945,8 @@ export class SshTui {
5982
5945
  const dialog = this.dialog;
5983
5946
  if (dialog === undefined || dialog.kind !== 'questions')
5984
5947
  return false;
5985
- const count = dialog.question.options?.length ?? 0;
5986
- if (count === 0)
5948
+ if (!moveQuestionCursor(dialog, delta))
5987
5949
  return false;
5988
- dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
5989
- if (dialog.question.multiSelect !== true) {
5990
- dialog.selected.clear();
5991
- dialog.selected.add(dialog.cursor);
5992
- }
5993
5950
  this.markDirty();
5994
5951
  return true;
5995
5952
  }
@@ -5998,9 +5955,8 @@ export class SshTui {
5998
5955
  if (dialog === undefined)
5999
5956
  return;
6000
5957
  if (dialog.kind === 'inspect') {
6001
- if (text === '\x1b' || text === '\x03' || text === 'q' || text === 'Q' || text === '\r' || text === '\n') {
5958
+ if (inspectClosesOn(text))
6002
5959
  this.closeInspect();
6003
- }
6004
5960
  return;
6005
5961
  }
6006
5962
  if (dialog.kind === 'onboarding') {
@@ -6008,45 +5964,28 @@ export class SshTui {
6008
5964
  return;
6009
5965
  }
6010
5966
  if (dialog.kind === 'confirm') {
6011
- if (text === 'y' || text === 'Y')
6012
- this.closeConfirm('y');
6013
- else if (text === 'n' || text === 'N')
6014
- this.closeConfirm('n');
6015
- else if (text === '\x03' || text === '\x1b')
6016
- this.closeConfirm('cancel');
5967
+ const answer = confirmAnswer(text);
5968
+ if (answer !== undefined)
5969
+ this.closeConfirm(answer);
6017
5970
  return;
6018
5971
  }
6019
- const key = text.toLowerCase();
6020
- const index = QUESTION_OPTION_KEYS.indexOf(key);
6021
- if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
6022
- dialog.cursor = index;
6023
- if (dialog.question.multiSelect === true) {
6024
- if (dialog.selected.has(index))
6025
- dialog.selected.delete(index);
6026
- else
6027
- dialog.selected.add(index);
6028
- }
6029
- else {
6030
- dialog.selected.clear();
6031
- dialog.selected.add(index);
6032
- }
5972
+ if (selectQuestionOptionByKey(dialog, text))
6033
5973
  this.markDirty();
6034
- }
6035
5974
  if (text === '\r' || text === '\n') {
6036
- const options = dialog.question.options ?? [];
6037
- const selected = [...dialog.selected].map(index => options[index]?.label).filter((label) => label !== undefined);
6038
- if (selected.length === 0 && options.length > 0 && dialog.question.multiSelect !== true) {
5975
+ const submit = questionSubmit(dialog, this.input);
5976
+ if (submit.kind === 'reject') {
6039
5977
  // no selection: treat as cancel unless there are no options
6040
5978
  dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
6041
5979
  return;
6042
5980
  }
6043
- if (options.length === 0) {
6044
- dialog.resolve({ selected: [], custom: this.input });
6045
- this.input = '';
6046
- this.cursor = 0;
5981
+ if (submit.kind === 'resolve') {
5982
+ dialog.resolve({ selected: submit.selected, ...(submit.custom === undefined ? {} : { custom: submit.custom }) });
5983
+ if (submit.custom !== undefined) {
5984
+ this.input = '';
5985
+ this.cursor = 0;
5986
+ }
6047
5987
  return;
6048
5988
  }
6049
- dialog.resolve({ selected });
6050
5989
  return;
6051
5990
  }
6052
5991
  if (text === '\x1b' || text === '\x03') {
@@ -6915,6 +6854,12 @@ export class SshTui {
6915
6854
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6916
6855
  }
6917
6856
  break;
6857
+ case 'diag':
6858
+ void this.runDiagCommand().catch((error) => {
6859
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
6860
+ this.markDirty();
6861
+ });
6862
+ break;
6918
6863
  case 'usage':
6919
6864
  case 'balance':
6920
6865
  case 'quota':
@@ -7194,10 +7139,6 @@ export class SshTui {
7194
7139
  }
7195
7140
  }
7196
7141
  }
7197
- function optionsLength(dialog) {
7198
- return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
7199
- }
7200
- /** Escape a string for safe interpolation into a RegExp source. */
7201
7142
  function escapeRegex(value) {
7202
7143
  return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
7203
7144
  }