dsh-ssh-tui 0.5.9 → 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 +28 -5
  2. package/README.md +76 -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 +304 -86
  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 -73
  22. package/lib/index.js.map +1 -1
  23. package/lib/paint.js +20 -38
  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 +257 -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 +59 -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 -3
  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 = [];
@@ -1385,6 +1475,11 @@ export class SshTui {
1385
1475
  applyProbedRtt(rttMs) {
1386
1476
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
1387
1477
  this.paintLink = 'ssh';
1478
+ // A reattach whose probe missed its window reports "unknown"; the link has
1479
+ // not changed, so dropping a measurement we already have just blanks the
1480
+ // footer chip to four hollow circles. Keep it (the next attach re-measures).
1481
+ if (rttMs === undefined && this.paintProbed && this.paintRttMs !== undefined)
1482
+ return;
1388
1483
  this.paintProbed = rttMs !== undefined;
1389
1484
  this.paintRttMs = rttMs;
1390
1485
  if (!(Number.isFinite(envOverride) && envOverride > 0)) {
@@ -1411,52 +1506,27 @@ export class SshTui {
1411
1506
  return parts.join(' · ');
1412
1507
  }
1413
1508
  mergeIntoToolCard(previous, next) {
1414
- const ids = previous.mergedCallIds ?? [previous.callId];
1415
- if (!ids.includes(previous.callId))
1416
- ids.push(previous.callId);
1417
- if (!ids.includes(next.callId))
1418
- ids.push(next.callId);
1419
- previous.mergedCallIds = ids;
1420
- previous.callId = next.callId;
1421
- previous.name = next.name;
1422
- previous.args = next.args;
1423
- if (next.title !== '')
1424
- previous.title = next.title;
1425
- if (next.summary !== '')
1426
- previous.summary = next.summary;
1427
- if (next.diff !== undefined && next.diff.length > 0) {
1428
- previous.diff = (previous.repeats ?? 1) > 1 && previous.diff !== undefined && previous.diff.length > 0
1429
- ? [...previous.diff, ...next.diff]
1430
- : next.diff;
1431
- }
1432
- previous.status = 'running';
1433
- previous.output = '';
1434
- previous.exitCode = undefined;
1435
- previous.signal = undefined;
1436
- previous.repeats = (previous.repeats ?? 1) + 1;
1437
- if (!this.replaying)
1438
- previous.flipUntil = Date.now() + TOOL_FLIP_MS;
1509
+ mergeToolCard(previous, next, Date.now(), this.replaying);
1439
1510
  }
1440
1511
  findToolRowByCallId(callId) {
1441
- return this.rows.findLast((candidate) => candidate.kind === 'tool'
1442
- && (candidate.callId === callId || candidate.mergedCallIds?.includes(callId) === true));
1512
+ return findToolRowByCallId(this.rows, callId);
1443
1513
  }
1444
1514
  findMergeableToolRow(next) {
1445
- const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
1446
- return canMergeToolCall(previous, next) ? previous : undefined;
1515
+ return findMergeableToolRow(this.rows, next);
1447
1516
  }
1448
1517
  /** Append one transcript row, bounding memory on long sessions. */
1449
1518
  pushRow(row) {
1450
1519
  this.rows.push(row);
1451
- if (this.rows.length > MAX_TRANSCRIPT_ROWS) {
1452
- const removed = this.rows.length - MAX_TRANSCRIPT_ROWS;
1453
- if (this.focusedRow !== null
1454
- && this.focusedRow.kind !== 'streaming-reasoning'
1455
- && this.rows.indexOf(this.focusedRow) < removed) {
1456
- this.focusedRow = null;
1457
- }
1458
- this.rows.splice(0, removed);
1459
- }
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;
1460
1530
  }
1461
1531
  /** The transcript rows that support per-row expand/collapse. */
1462
1532
  collapsibleRows() {
@@ -1528,29 +1598,15 @@ export class SshTui {
1528
1598
  ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
1529
1599
  };
1530
1600
  }
1531
- planShouldDefaultExpand(plan) {
1532
- return plan.active === true
1533
- || plan.pending === true
1534
- || plan.todos.some(item => item.status === 'in_progress');
1535
- }
1536
1601
  findSubagentRow(sessionId) {
1537
1602
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
1538
1603
  }
1539
1604
  findLivePlanRow() {
1540
- return this.rows.findLast((row) => row.kind === 'plan' && planIsLive(row));
1605
+ return findLivePlanRow(this.rows);
1541
1606
  }
1542
1607
  /** Older / finished plans stay in the scrolling transcript. */
1543
1608
  archiveStalePlans(keep) {
1544
- for (const row of this.rows) {
1545
- if (row.kind !== 'plan' || row === keep)
1546
- continue;
1547
- if (row.archived === true)
1548
- continue;
1549
- row.archived = true;
1550
- row.active = false;
1551
- row.pending = false;
1552
- row.expanded = false;
1553
- }
1609
+ archiveStalePlans(this.rows, keep);
1554
1610
  }
1555
1611
  upsertPlanRow(patch) {
1556
1612
  const existing = this.findLivePlanRow();
@@ -1568,7 +1624,7 @@ export class SshTui {
1568
1624
  existing.archived = true;
1569
1625
  existing.expanded = false;
1570
1626
  }
1571
- else if (patch.expanded === undefined && this.planShouldDefaultExpand(existing)) {
1627
+ else if (patch.expanded === undefined && planShouldDefaultExpand(existing)) {
1572
1628
  existing.expanded = true;
1573
1629
  }
1574
1630
  this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
@@ -1580,19 +1636,16 @@ export class SshTui {
1580
1636
  existing.pending = false;
1581
1637
  existing.expanded = false;
1582
1638
  }
1639
+ const active = patch.active ?? false;
1640
+ const pending = patch.pending ?? false;
1641
+ const todos = patch.todos ?? [];
1583
1642
  const row = {
1584
1643
  kind: 'plan',
1585
- active: patch.active ?? false,
1586
- pending: patch.pending ?? false,
1587
- todos: patch.todos ?? [],
1644
+ active,
1645
+ pending,
1646
+ todos,
1588
1647
  ...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
1589
- expanded: this.planShouldDefaultExpand({
1590
- kind: 'plan',
1591
- active: patch.active ?? false,
1592
- pending: patch.pending ?? false,
1593
- todos: patch.todos ?? [],
1594
- expanded: false,
1595
- }),
1648
+ expanded: planShouldDefaultExpand({ active, pending, todos }),
1596
1649
  archived: false,
1597
1650
  };
1598
1651
  this.pushRow(row);
@@ -2533,29 +2586,18 @@ export class SshTui {
2533
2586
  const inputDivider = this.styleLine('system', repeatToWidth('─', width));
2534
2587
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
2535
2588
  const available = Math.max(0, height - reserved - dialogLines.length);
2536
- const maxOffset = Math.max(0, display.length - available);
2537
- const reveal = this.pendingReveal;
2538
- if (reveal !== undefined) {
2539
- this.pendingReveal = undefined;
2540
- const first = displayRefs.findIndex(ref => ref === reveal);
2541
- if (first !== -1) {
2542
- let last = first;
2543
- while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
2544
- last += 1;
2545
- const span = last - first + 1;
2546
- this.scrollOffset = Math.max(0, display.length - available - first);
2547
- }
2548
- }
2549
- if (this.scrollOffset > maxOffset)
2550
- this.scrollOffset = maxOffset;
2551
- const start = Math.max(0, display.length - available - this.scrollOffset);
2552
- const visible = display.slice(start, start + available);
2553
- const visibleRefs = displayRefs.slice(start, start + available);
2554
- const padding = Math.max(0, available - visible.length);
2555
- for (let index = 0; index < padding; index++) {
2556
- visible.unshift('');
2557
- visibleRefs.unshift(undefined);
2558
- }
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;
2559
2601
  this.clickableRows.clear();
2560
2602
  this.paintedLinkHitsByRow.clear();
2561
2603
  for (let index = 0; index < visibleRefs.length; index++) {
@@ -2573,20 +2615,7 @@ export class SshTui {
2573
2615
  this.clickableRows.set(dockTop, dockPlan);
2574
2616
  }
2575
2617
  const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
2576
- const statsGroups = footerStatsGroups({
2577
- turns: this.stats.turns,
2578
- steps: this.stats.steps,
2579
- llmMs: this.stats.llmMs,
2580
- toolMs: this.stats.toolMs,
2581
- ttftMs: this.stats.ttftMs,
2582
- ttftSteps: this.stats.ttftSteps,
2583
- decodeMs: this.stats.decodeMs,
2584
- decodeTokens: this.stats.decodeTokens,
2585
- inputTokens: this.stats.usage.inputTokens,
2586
- outputTokens: this.stats.usage.outputTokens,
2587
- cacheReadTokens: this.stats.usage.cacheReadTokens,
2588
- cacheWriteTokens: this.stats.usage.cacheWriteTokens,
2589
- });
2618
+ const statsGroups = footerStatsGroups(statsRowOf(this.statsTracker.snapshot()));
2590
2619
  const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
2591
2620
  const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
2592
2621
  const statsLine = statsPlain.startsWith(chipVisible)
@@ -2737,19 +2766,15 @@ export class SshTui {
2737
2766
  this.markDirty();
2738
2767
  }
2739
2768
  buildSuggestions() {
2740
- const input = this.input;
2741
- 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('/'))
2742
2772
  return [];
2743
- const prefix = input.slice(1).toLowerCase();
2744
- const local = localizedCommands()
2745
- .filter(command => command.name !== 'dialog-test' && (prefix !== '' || command.aliasOf === undefined))
2746
- .map(command => ({ name: command.name, description: command.description, local: true }));
2747
- const seen = new Set(local.map(command => command.name));
2748
- const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
2749
- .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) ?? [])
2750
2776
  .map(command => {
2751
- const descKey = `cmd.${command.name}`;
2752
- const desc = t(descKey, undefined, command.description);
2777
+ const desc = t(`cmd.${command.name}`, undefined, command.description);
2753
2778
  return {
2754
2779
  name: command.name,
2755
2780
  description: commandAcceptsAttachments(command.input)
@@ -2758,17 +2783,7 @@ export class SshTui {
2758
2783
  local: false,
2759
2784
  };
2760
2785
  });
2761
- const all = [...local, ...dsh];
2762
- const filtered = prefix === ''
2763
- ? all
2764
- : all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
2765
- if (prefix === '')
2766
- return filtered;
2767
- return filtered.sort((a, b) => {
2768
- const aStart = a.name.startsWith(prefix) ? 0 : 1;
2769
- const bStart = b.name.startsWith(prefix) ? 0 : 1;
2770
- return aStart - bStart;
2771
- });
2786
+ return commandSuggestions(this.input, foreign);
2772
2787
  }
2773
2788
  suggestionsVisible() {
2774
2789
  return this.commandSuggestions.length > 0 && this.dialog === undefined;
@@ -2784,41 +2799,9 @@ export class SshTui {
2784
2799
  const kind = describeProviderRoute(provider).short;
2785
2800
  return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
2786
2801
  }
2787
- /** Replace one step's usage sample so a repeated report never double counts. */
2788
- recordUsage(turn, step, usage) {
2789
- const key = `${turn}:${step}`;
2790
- const next = {
2791
- inputTokens: usage.inputTokens,
2792
- outputTokens: usage.outputTokens,
2793
- cacheReadTokens: usage.cacheReadTokens ?? 0,
2794
- cacheWriteTokens: usage.cacheWriteTokens ?? 0,
2795
- };
2796
- const previous = this.usageByStep.get(key);
2797
- const totals = this.stats.usage;
2798
- this.stats.usage = {
2799
- inputTokens: totals.inputTokens - (previous?.inputTokens ?? 0) + next.inputTokens,
2800
- outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
2801
- cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
2802
- cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
2803
- };
2804
- this.usageByStep.set(key, next);
2805
- }
2806
2802
  /** Compact session stats groups for the first footer row. */
2807
2803
  statsText() {
2808
- return footerStatsGroups({
2809
- turns: this.stats.turns,
2810
- steps: this.stats.steps,
2811
- llmMs: this.stats.llmMs,
2812
- toolMs: this.stats.toolMs,
2813
- ttftMs: this.stats.ttftMs,
2814
- ttftSteps: this.stats.ttftSteps,
2815
- decodeMs: this.stats.decodeMs,
2816
- decodeTokens: this.stats.decodeTokens,
2817
- inputTokens: this.stats.usage.inputTokens,
2818
- outputTokens: this.stats.usage.outputTokens,
2819
- cacheReadTokens: this.stats.usage.cacheReadTokens,
2820
- cacheWriteTokens: this.stats.usage.cacheWriteTokens,
2821
- }).join(' │ ');
2804
+ return footerStatsGroups(statsRowOf(this.statsTracker.snapshot())).join(' │ ');
2822
2805
  }
2823
2806
  /** Refresh the terminal window title (throttled while running). */
2824
2807
  updateTerminalTitle() {
@@ -2919,15 +2902,11 @@ export class SshTui {
2919
2902
  */
2920
2903
  applyStreamChunk(streamed, statsOnly = false) {
2921
2904
  const { chunk } = streamed;
2922
- const open = this.openStepStats;
2923
- if (open !== null && open !== undefined
2924
- && open.turn === streamed.turn && open.step === streamed.step) {
2925
- if (open.firstTokenTime === null && isTokenDeltaChunk(chunk)) {
2926
- this.openStepStats = { ...open, firstTokenTime: streamed.time };
2927
- }
2905
+ if (isTokenDeltaChunk(chunk)) {
2906
+ this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
2928
2907
  }
2929
2908
  if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
2930
- this.recordUsage(streamed.turn, streamed.step, chunk.usage);
2909
+ this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
2931
2910
  }
2932
2911
  if (statsOnly)
2933
2912
  return;
@@ -2977,7 +2956,7 @@ export class SshTui {
2977
2956
  const current = this.liveStreamOwner;
2978
2957
  const fallback = current !== undefined && (attemptId === undefined || attemptId === current.attemptId)
2979
2958
  ? current
2980
- : this.openStepStats;
2959
+ : this.statsTracker.currentStep();
2981
2960
  const streamed = streamChunkOf(payload.frame, fallback);
2982
2961
  if (streamed !== undefined)
2983
2962
  this.applyStreamChunk(streamed);
@@ -3088,29 +3067,19 @@ export class SshTui {
3088
3067
  .filter(block => block.type === 'reasoning')
3089
3068
  .map(block => block.text)
3090
3069
  .join('');
3091
- const open = this.openStepStats;
3092
- if (open !== undefined && open.turn === event.data.turn && open.step === event.data.step) {
3093
- this.stats.llmMs += Math.max(0, event.time - open.startTime);
3094
- // The settlement's own packed stream is authoritative: a retried
3095
- // step keeps the failed attempt's first token in the live latch, and
3096
- // spanning both attempts reported a rate ~20x off. 0.1.2 has no
3097
- // `stream` and still relies on the replayed `assistant/chunk` events
3098
- // (or the live latch) instead.
3099
- const firstTokenTime = streamFirstTokenTime(event.data.stream)
3100
- ?? open.firstTokenTime;
3101
- if (firstTokenTime !== null && firstTokenTime !== undefined && Number.isFinite(firstTokenTime)) {
3102
- this.stats.ttftMs += Math.max(0, firstTokenTime - open.startTime);
3103
- this.stats.ttftSteps += 1;
3104
- const outputTokens = event.data.usage?.outputTokens;
3105
- if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
3106
- this.stats.decodeMs += Math.max(0, event.time - firstTokenTime);
3107
- this.stats.decodeTokens += outputTokens;
3108
- }
3109
- }
3110
- this.openStepStats = undefined;
3111
- }
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
+ });
3112
3081
  if (event.data.usage !== undefined) {
3113
- this.recordUsage(event.data.turn, event.data.step, event.data.usage);
3082
+ this.statsTracker.recordUsage(event.data.turn, event.data.step, event.data.usage);
3114
3083
  }
3115
3084
  const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
3116
3085
  const interrupted = event.data.interrupted === true;
@@ -3133,7 +3102,7 @@ export class SshTui {
3133
3102
  case 'tool/call': {
3134
3103
  this.openToolCalls.set(String(event.data.callId), event.data.name);
3135
3104
  this.toolCallNames.set(String(event.data.callId), event.data.name);
3136
- this.pendingToolTimes.set(String(event.data.callId), event.time);
3105
+ this.statsTracker.noteToolStart(String(event.data.callId), event.time);
3137
3106
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
3138
3107
  const present = presentToolCall(event.data.name, event.data.arguments);
3139
3108
  const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
@@ -3176,11 +3145,7 @@ export class SshTui {
3176
3145
  }
3177
3146
  case 'tool/result': {
3178
3147
  this.openToolCalls.delete(String(event.data.message.source.callId));
3179
- const dispatchedAt = this.pendingToolTimes.get(String(event.data.message.source.callId));
3180
- if (dispatchedAt !== undefined) {
3181
- this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
3182
- this.pendingToolTimes.delete(String(event.data.message.source.callId));
3183
- }
3148
+ this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
3184
3149
  const callId = String(event.data.message.source.callId);
3185
3150
  const row = this.findToolRowByCallId(callId);
3186
3151
  const output = collectText(event.data.message.content);
@@ -3240,25 +3205,12 @@ export class SshTui {
3240
3205
  break;
3241
3206
  }
3242
3207
  case 'step/start': {
3243
- this.openStepStats = {
3244
- turn: event.data.turn,
3245
- step: event.data.step,
3246
- startTime: event.time,
3247
- firstTokenTime: null,
3248
- };
3208
+ this.statsTracker.noteStepStart(event.data.turn, event.data.step, event.time);
3249
3209
  this.markDirty();
3250
3210
  break;
3251
3211
  }
3252
3212
  case 'step/end': {
3253
- if (this.lastStatsTurn !== event.data.turn) {
3254
- this.stats.turns += 1;
3255
- this.lastStatsTurn = event.data.turn;
3256
- }
3257
- this.stats.steps += 1;
3258
- this.openStepStats = undefined;
3259
- // Usage accounting is complete for this step; the map only exists to
3260
- // deduplicate repeated usage reports during the step.
3261
- this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
3213
+ this.statsTracker.noteStepEnd(event.data.turn, event.data.step);
3262
3214
  if (!this.replaying) {
3263
3215
  this.quotaStepsSinceRefresh += 1;
3264
3216
  const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
@@ -3290,7 +3242,7 @@ export class SshTui {
3290
3242
  const reason = event.data.reason;
3291
3243
  this.openToolCalls.clear();
3292
3244
  this.toolCallNames.clear();
3293
- this.pendingToolTimes.clear();
3245
+ this.statsTracker.noteTurnEnd();
3294
3246
  this.stalledWarningShown = false;
3295
3247
  this.pendingMessages.clear();
3296
3248
  // Aborted/errored turns may close without an assembled
@@ -3339,6 +3291,9 @@ export class SshTui {
3339
3291
  return;
3340
3292
  this.lastActivity = Date.now();
3341
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;
3342
3297
  this.completionSignaled = false;
3343
3298
  this.completedAt = 0;
3344
3299
  if (this.waitStartedAt === undefined)
@@ -3357,6 +3312,14 @@ export class SshTui {
3357
3312
  if (status !== 'running') {
3358
3313
  this.flushPlanCloseNudge();
3359
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();
3360
3323
  }
3361
3324
  this.markDirty();
3362
3325
  };
@@ -5600,7 +5563,12 @@ export class SshTui {
5600
5563
  }
5601
5564
  // ── keyboard ────────────────────────────────────────────────────────────
5602
5565
  handleData = (chunk) => {
5603
- 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) {
5604
5572
  const combined = this.escapeBuffer + text;
5605
5573
  this.escapeBuffer = '';
5606
5574
  if (this.escapeTimer !== undefined) {
@@ -5791,7 +5759,7 @@ export class SshTui {
5791
5759
  return;
5792
5760
  }
5793
5761
  this.handlePlainText(combined);
5794
- };
5762
+ }
5795
5763
  /** Handle one data chunk that may contain bracketed-paste markers. */
5796
5764
  processPasteChunk(combined) {
5797
5765
  let index = 0;
@@ -5977,14 +5945,8 @@ export class SshTui {
5977
5945
  const dialog = this.dialog;
5978
5946
  if (dialog === undefined || dialog.kind !== 'questions')
5979
5947
  return false;
5980
- const count = dialog.question.options?.length ?? 0;
5981
- if (count === 0)
5948
+ if (!moveQuestionCursor(dialog, delta))
5982
5949
  return false;
5983
- dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
5984
- if (dialog.question.multiSelect !== true) {
5985
- dialog.selected.clear();
5986
- dialog.selected.add(dialog.cursor);
5987
- }
5988
5950
  this.markDirty();
5989
5951
  return true;
5990
5952
  }
@@ -5993,9 +5955,8 @@ export class SshTui {
5993
5955
  if (dialog === undefined)
5994
5956
  return;
5995
5957
  if (dialog.kind === 'inspect') {
5996
- if (text === '\x1b' || text === '\x03' || text === 'q' || text === 'Q' || text === '\r' || text === '\n') {
5958
+ if (inspectClosesOn(text))
5997
5959
  this.closeInspect();
5998
- }
5999
5960
  return;
6000
5961
  }
6001
5962
  if (dialog.kind === 'onboarding') {
@@ -6003,45 +5964,28 @@ export class SshTui {
6003
5964
  return;
6004
5965
  }
6005
5966
  if (dialog.kind === 'confirm') {
6006
- if (text === 'y' || text === 'Y')
6007
- this.closeConfirm('y');
6008
- else if (text === 'n' || text === 'N')
6009
- this.closeConfirm('n');
6010
- else if (text === '\x03' || text === '\x1b')
6011
- this.closeConfirm('cancel');
5967
+ const answer = confirmAnswer(text);
5968
+ if (answer !== undefined)
5969
+ this.closeConfirm(answer);
6012
5970
  return;
6013
5971
  }
6014
- const key = text.toLowerCase();
6015
- const index = QUESTION_OPTION_KEYS.indexOf(key);
6016
- if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
6017
- dialog.cursor = index;
6018
- if (dialog.question.multiSelect === true) {
6019
- if (dialog.selected.has(index))
6020
- dialog.selected.delete(index);
6021
- else
6022
- dialog.selected.add(index);
6023
- }
6024
- else {
6025
- dialog.selected.clear();
6026
- dialog.selected.add(index);
6027
- }
5972
+ if (selectQuestionOptionByKey(dialog, text))
6028
5973
  this.markDirty();
6029
- }
6030
5974
  if (text === '\r' || text === '\n') {
6031
- const options = dialog.question.options ?? [];
6032
- const selected = [...dialog.selected].map(index => options[index]?.label).filter((label) => label !== undefined);
6033
- if (selected.length === 0 && options.length > 0 && dialog.question.multiSelect !== true) {
5975
+ const submit = questionSubmit(dialog, this.input);
5976
+ if (submit.kind === 'reject') {
6034
5977
  // no selection: treat as cancel unless there are no options
6035
5978
  dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
6036
5979
  return;
6037
5980
  }
6038
- if (options.length === 0) {
6039
- dialog.resolve({ selected: [], custom: this.input });
6040
- this.input = '';
6041
- 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
+ }
6042
5987
  return;
6043
5988
  }
6044
- dialog.resolve({ selected });
6045
5989
  return;
6046
5990
  }
6047
5991
  if (text === '\x1b' || text === '\x03') {
@@ -6910,6 +6854,12 @@ export class SshTui {
6910
6854
  this.pushRow({ kind: 'system', text: lines.join('\n') });
6911
6855
  }
6912
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;
6913
6863
  case 'usage':
6914
6864
  case 'balance':
6915
6865
  case 'quota':
@@ -7189,10 +7139,6 @@ export class SshTui {
7189
7139
  }
7190
7140
  }
7191
7141
  }
7192
- function optionsLength(dialog) {
7193
- return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
7194
- }
7195
- /** Escape a string for safe interpolation into a RegExp source. */
7196
7142
  function escapeRegex(value) {
7197
7143
  return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
7198
7144
  }