dsh-ssh-tui 0.7.4 → 0.8.1

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 (74) hide show
  1. package/README.en.md +37 -0
  2. package/README.md +436 -572
  3. package/docs/display-mode.md +122 -0
  4. package/docs/remote-ops.md +104 -0
  5. package/docs/terminals.md +53 -0
  6. package/lib/attach.js +4 -4
  7. package/lib/attach.js.map +1 -1
  8. package/lib/auth-failure.js +128 -0
  9. package/lib/auth-failure.js.map +1 -0
  10. package/lib/commands.js +3 -0
  11. package/lib/commands.js.map +1 -1
  12. package/lib/dialogs.js +43 -0
  13. package/lib/dialogs.js.map +1 -1
  14. package/lib/display-mode.js +147 -0
  15. package/lib/display-mode.js.map +1 -0
  16. package/lib/display-sock.js +361 -21
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/footer.js +6 -9
  19. package/lib/footer.js.map +1 -1
  20. package/lib/glyph-measure.js +92 -0
  21. package/lib/glyph-measure.js.map +1 -0
  22. package/lib/i18n/en.js +27 -2
  23. package/lib/i18n/en.js.map +1 -1
  24. package/lib/i18n/zh.js +27 -2
  25. package/lib/i18n/zh.js.map +1 -1
  26. package/lib/index.js +74 -5
  27. package/lib/index.js.map +1 -1
  28. package/lib/paint.js +22 -9
  29. package/lib/paint.js.map +1 -1
  30. package/lib/picker.js +14 -13
  31. package/lib/picker.js.map +1 -1
  32. package/lib/plan.js +11 -11
  33. package/lib/plan.js.map +1 -1
  34. package/lib/platform.js +96 -0
  35. package/lib/platform.js.map +1 -1
  36. package/lib/session-blank.js +81 -0
  37. package/lib/session-blank.js.map +1 -0
  38. package/lib/session-list.js +102 -81
  39. package/lib/session-list.js.map +1 -1
  40. package/lib/startup.js +7 -0
  41. package/lib/startup.js.map +1 -1
  42. package/lib/subagent-model.js +8 -7
  43. package/lib/subagent-model.js.map +1 -1
  44. package/lib/term-text.js +296 -28
  45. package/lib/term-text.js.map +1 -1
  46. package/lib/terminal-input.js +132 -10
  47. package/lib/terminal-input.js.map +1 -1
  48. package/lib/theme.js +318 -0
  49. package/lib/theme.js.map +1 -0
  50. package/lib/tool-present.js +11 -9
  51. package/lib/tool-present.js.map +1 -1
  52. package/lib/tui.js +535 -37
  53. package/lib/tui.js.map +1 -1
  54. package/lib/types/attach.d.ts +6 -2
  55. package/lib/types/auth-failure.d.ts +78 -0
  56. package/lib/types/commands.d.ts +9 -0
  57. package/lib/types/dialogs.d.ts +36 -0
  58. package/lib/types/display-mode.d.ts +99 -0
  59. package/lib/types/display-sock.d.ts +75 -0
  60. package/lib/types/footer.d.ts +1 -1
  61. package/lib/types/glyph-measure.d.ts +41 -0
  62. package/lib/types/index.d.ts +20 -0
  63. package/lib/types/plan.d.ts +5 -2
  64. package/lib/types/platform.d.ts +83 -0
  65. package/lib/types/session-blank.d.ts +51 -0
  66. package/lib/types/session-list.d.ts +34 -0
  67. package/lib/types/startup.d.ts +6 -0
  68. package/lib/types/subagent-model.d.ts +7 -6
  69. package/lib/types/term-text.d.ts +55 -23
  70. package/lib/types/terminal-input.d.ts +37 -0
  71. package/lib/types/theme.d.ts +109 -0
  72. package/lib/types/tool-present.d.ts +2 -2
  73. package/lib/types/tui.d.ts +121 -1
  74. package/package.json +92 -93
package/lib/tui.js CHANGED
@@ -32,22 +32,26 @@ import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from '.
32
32
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
33
33
  import { catalogContextWindow, catalogWindowIndex, suggestedRouteContextWindow, } from './context-window.js';
34
34
  import { formatFooterCwd } from './session-list.js';
35
+ import { pruneBlankSessions } from './session-list.js';
35
36
  import { collectDiag, formatDiag } from './diag.js';
36
37
  import { ApprovalVerdictCache, cacheableShape, verdictKey } from './approval-cache.js';
37
38
  import { collectDoctor, doctorChecks, formatDoctorReport, rowsToRepair } from './doctor.js';
38
39
  import { colorDepth, downgradeSgr } from './color-depth.js';
39
40
  import { appendRow, lineModeEnabled, lineModeLines } from './line-mode.js';
40
41
  import { keymapReport, resolveKeymap } from './keymap.js';
42
+ import { classifyAuthFailure, isReasoningReplayFailure } from './auth-failure.js';
43
+ import { resolveTheme, setActiveTheme, themeByName, themeEmphasisToken, themeExtraToken, themeNames, themeToken, } from './theme.js';
41
44
  import { presetLabel, profileFromArgv } from './preset-label.js';
42
45
  import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
43
46
  import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
44
47
  import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
45
48
  import { SessionStatsTracker, statsRowOf } from './stats.js';
46
- import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
49
+ import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, initialQuestionSelection, questionOptionMarker, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
47
50
  import { commandSuggestions, localizedCommands } from './commands.js';
48
51
  import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
49
52
  import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
50
53
  import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
54
+ import { asciiFallbackEnabled } from './platform.js';
51
55
  import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
52
56
  import { sameSessionRoute, sessionRouteInput } from './session-route.js';
53
57
  import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
@@ -62,7 +66,7 @@ import { displayToolName, subagentCourtesyName } from './job-label.js';
62
66
  import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
63
67
  import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
64
68
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
65
- import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, highlightAnsiNeedle, searchContains, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, stripAnsi, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
69
+ import { clipAnsiToWidth, cursorVisualPosition, displayWidth, fmtElapsedCompact, foldInputView, highlightAnsiNeedle, hrefAtColumn, lastCodePoints, osc52Clipboard, padToWidth, paintSegmentedLine, paintedLinkHits, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, searchContains, setAmbiguousWidthMeasured, setAmbiguousWidthReserve, shimmerText, sliceCodePoints, stripAnsi, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
66
70
  import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
67
71
  import { captureHangupSignals, advancePaintedRows, composePaintFrame, composePaintOutput, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
68
72
  import { TerminalInputGuard } from './terminal-input.js';
@@ -230,6 +234,21 @@ function isModelsStep(step) {
230
234
  const COMPACTION_STALE_MS = 15 * 60_000;
231
235
  /** How many `<family>\0<model>` → row decisions are remembered. */
232
236
  const FAMILY_OWNER_CACHE_MAX = 512;
237
+ /** Lifecycle handle for a mounted interactive terminal channel. */
238
+ /**
239
+ * Row fields the display rendering reads, for the per-row render cache.
240
+ *
241
+ * Kept next to the cache's fingerprint rather than beside the row type: the list
242
+ * only has to be complete for *rendering* purposes, and a single place makes it
243
+ * obvious what to extend when the rendering starts reading something new.
244
+ */
245
+ const SCALAR_KEY_FIELDS = [
246
+ 'kind', 'text', 'summary', 'detail', 'header', 'title', 'output', 'planMarkdown',
247
+ 'blockedReason', 'diff', 'command', 'expanded', 'archived', 'status', 'phase', 'signal',
248
+ 'more', 'startedAt', 'endedAt', 'flipUntil', 'modelProvider', 'provider', 'model', 'id', 'callId',
249
+ ];
250
+ /** Row fields holding structures the rendering walks; compared one level deep. */
251
+ const NESTED_KEY_FIELDS = ['todos', 'sources', 'intent'];
233
252
  /**
234
253
  * A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
235
254
  * Locale-neutral on purpose — it reads the same in both catalogs.
@@ -281,10 +300,6 @@ const LINE_MODE_INSPECT_LINES = 80;
281
300
  * a changed word is marked without stepping outside the muted palette. The
282
301
  * fills they lighten live in `styleLine` (`diff-add` / `diff-del`).
283
302
  */
284
- const DIFF_EMPHASIS_SGR = {
285
- 'diff-add': '38;2;198;232;190;48;2;34;72;44',
286
- 'diff-del': '38;2;246;206;206;48;2;86;34;34',
287
- };
288
303
  const STALL_WARNING_MS = 60000;
289
304
  /** Bytes already queued for the terminal before a frame is skipped instead. */
290
305
  const STDOUT_BACKLOG_BYTES = 32 * 1024;
@@ -681,6 +696,149 @@ export class SshTui {
681
696
  commandSuggestions = [];
682
697
  suggestionIndex = 0;
683
698
  focusedRow = null;
699
+ /**
700
+ * The active palette. Roles resolve through `theme.ts`, so switching themes is
701
+ * a repaint, not a re-render — no row holds a colour of its own.
702
+ */
703
+ theme = resolveTheme(undefined);
704
+ /**
705
+ * Rendered display lines per row, keyed by the row object.
706
+ *
707
+ * Every frame used to re-render the whole transcript — markdown parsing, card
708
+ * layout, wrapping and clipping for thousands of rows — to show the twenty on
709
+ * screen. Measured on a 5000-row session that was ~550 ms per frame, which is
710
+ * what "rendering feels slower the longer the session runs" was: a keystroke
711
+ * costs the same as a full repaint because both redo all of it.
712
+ *
713
+ * The cache is `WeakMap`-keyed on the row object, so a replaced row starts a new
714
+ * entry and an unreferenced one is collected. What each entry stores is a
715
+ * *fingerprint* of every input the row's rendering depends on plus the lines it
716
+ * produced; a row whose fingerprint is unchanged replays instead of re-rendering,
717
+ * which is the common case — during a turn only the streaming row changes.
718
+ */
719
+ displayRowCache = new WeakMap();
720
+ /** Fingerprint of the state that affects *every* row's rendering. */
721
+ displayBaseKey(width) {
722
+ return [
723
+ width,
724
+ String(this.isCompactView()),
725
+ this.showReasoning === true ? 'r' : '-',
726
+ this.searchIndex,
727
+ this.searchHits.length,
728
+ this.theme.name,
729
+ this.colorDepth,
730
+ this.focusedRow === null ? '' : String(this.focusedRow.kind ?? 'block'),
731
+ String(this.paintTailBudget),
732
+ this.pendingReveal === undefined ? '' : String(this.pendingReveal),
733
+ asciiFallbackEnabled() ? 'ascii' : 'utf8',
734
+ ].join('|');
735
+ }
736
+ /**
737
+ * Fingerprint of one tool burst.
738
+ *
739
+ * A burst is a *group of tool rows drawn inside the reply above them*, so the
740
+ * reply's cached lines contain cards belonging to rows the reply's own
741
+ * fingerprint never sees. Without this, the reply kept replaying its cached
742
+ * burst: a finished tool went on saying "processing" and the stale card stayed
743
+ * on screen beside the live one — the residue a reader reported twice.
744
+ */
745
+ static burstKey(groups) {
746
+ const one = (rows) => rows.map(row => {
747
+ const loose = row;
748
+ const output = typeof loose.output === 'string' ? loose.output.length : 0;
749
+ return `${String(loose.callId ?? '')}/${String(loose.status ?? '')}/${output}`;
750
+ }).join(',');
751
+ return `${one(groups.edits)}#${one(groups.calls)}`;
752
+ }
753
+ /**
754
+ * Whether a row's rendering depends on the clock.
755
+ *
756
+ * A running card draws a spinner and an elapsed time, and a streaming row is
757
+ * being appended to: their *fields* may not change between two frames while
758
+ * their lines must. Such a row gets a tick in its cache key, so it re-renders
759
+ * every frame and becomes cacheable again once it settles. A frozen spinner was
760
+ * the alternative — and, once the same card was also drawn live elsewhere, the
761
+ * duplicate a reader actually reported.
762
+ */
763
+ static isLiveRow(row) {
764
+ const loose = row;
765
+ if (String(loose.kind ?? '').startsWith('streaming'))
766
+ return true;
767
+ if (loose.status === 'running')
768
+ return true;
769
+ if (loose.endedAt === undefined && loose.startedAt !== undefined)
770
+ return true;
771
+ const flipUntil = loose.flipUntil;
772
+ return typeof flipUntil === 'number' && flipUntil > Date.now();
773
+ }
774
+ /**
775
+ * Fingerprint of everything one row's rendering reads.
776
+ *
777
+ * Written by hand rather than hashing the object: the rendering reads a known
778
+ * set of fields, and a generic walk would cost more per frame than the render
779
+ * it is meant to avoid. Scalar fields are compared by value; nested arrays the
780
+ * rendering walks (todos, sources, intent) get a shallow signature of their own
781
+ * scalars, because those are updated in place.
782
+ *
783
+ * The list is a contract: a field the rendering starts reading must be added
784
+ * here, or a frame replays stale lines. A test mutates one field in place and
785
+ * asserts the next frame notices, which is the pattern to copy for a new field.
786
+ */
787
+ static rowKey(row) {
788
+ const loose = row;
789
+ let key = '';
790
+ for (const field of SCALAR_KEY_FIELDS) {
791
+ const value = loose[field];
792
+ key += typeof value === 'string' ? `${value.length}:${value.charCodeAt(0)}:${value.charCodeAt(value.length - 1)};`
793
+ : value === undefined ? '-;'
794
+ : `${String(value)};`;
795
+ }
796
+ for (const field of NESTED_KEY_FIELDS) {
797
+ const value = loose[field];
798
+ if (value === undefined) {
799
+ key += '-;';
800
+ continue;
801
+ }
802
+ if (!Array.isArray(value)) {
803
+ key += `o${Object.keys(value).length};`;
804
+ continue;
805
+ }
806
+ key += `a${value.length}:`;
807
+ // Bounded: a list longer than this changes its length when it changes, and
808
+ // walking hundreds of items per frame would defeat the cache.
809
+ for (const item of value.slice(0, 24)) {
810
+ if (item === null || typeof item !== 'object') {
811
+ key += String(item) + ',';
812
+ continue;
813
+ }
814
+ for (const [name, inner] of Object.entries(item)) {
815
+ key += name + '=' + (typeof inner === 'string' ? inner.length : String(inner)) + ',';
816
+ }
817
+ key += '|';
818
+ }
819
+ key += ';';
820
+ }
821
+ return key;
822
+ }
823
+ static rowRendersEqual(row, entry, key) {
824
+ return entry !== undefined && entry.key === key;
825
+ }
826
+ /**
827
+ * The text of the last prompt the user sent, kept so an opted-in retry can
828
+ * send the same thing again after a provider-side auth failure. Cleared when
829
+ * the retry fires, which is what bounds it to one attempt per user message.
830
+ */
831
+ /**
832
+ * Whether this session ever saw the reader's input.
833
+ *
834
+ * A fresh launch creates a session before anything is typed; quitting straight
835
+ * away leaves an artifact the TUI hides but other profiles' menus list. On the
836
+ * way out, a session that never saw input is deleted (see `session-blank.ts`).
837
+ */
838
+ sawUserInput = false;
839
+ lastUserText = '';
840
+ /** Set on every turn/start; a retry consumes it. */
841
+ authRetryArmed = false;
684
842
  pendingMessages = new Map();
685
843
  lastActivity = Date.now();
686
844
  lastIdleCtrlCAt = 0;
@@ -859,6 +1017,11 @@ export class SshTui {
859
1017
  // The palette the terminal can take, decided once: a truecolor pair on an
860
1018
  // 8-colour terminal is not a cosmetic loss, it is the wrong colour.
861
1019
  this.colorDepth = config.color === false ? 'none' : colorDepth(process.env);
1020
+ // The theme is a live setting: `DSH_TUI_THEME` wins for one launch, the
1021
+ // `ssh-tui` settings section remembers the user's choice across them.
1022
+ // Publish as well as store: the footer, plan dock, tool cards and markdown
1023
+ // renderers have no session handle and read the active palette.
1024
+ this.theme = setActiveTheme(process.env.DSH_TUI_THEME ?? this.readThemeName());
862
1025
  this.color = this.colorDepth !== 'none';
863
1026
  this.lastSessionRoute = config.restoredRoute;
864
1027
  this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
@@ -1268,7 +1431,10 @@ export class SshTui {
1268
1431
  addDisplay(this.selectLine(styled, this.focusedRow === anchor), anchor);
1269
1432
  // A collapsed burst is all the reader sees, so the two things that decide
1270
1433
  // whether to expand belong on it: which files moved, and what failed.
1271
- if (kind === 'edits') {
1434
+ // The breakdown is for a burst that touched *several* files: with one
1435
+ // file the header below already carries its path and its diffstat, and
1436
+ // repeating them printed the same line twice.
1437
+ if (kind === 'edits' && groups.edits.length > 1) {
1272
1438
  const stats = compactFileStats(groups.edits);
1273
1439
  const shown = stats.slice(0, MAX_COMPACT_FILE_STATS);
1274
1440
  const parts = shown.map(stat => `${stat.path} ${diffStatToken(stat.add, stat.del)}`.trim());
@@ -1280,7 +1446,9 @@ export class SshTui {
1280
1446
  }
1281
1447
  }
1282
1448
  for (const line of compactFailureLines(items)) {
1283
- addDisplay(this.styleLine('error', truncateToWidth(` ${line}`, width)), anchor);
1449
+ // Same grammar as the header: the state ball is what marks a line as a
1450
+ // tool's, and a failure that is only red text loses that reading.
1451
+ addDisplay(this.styleLine('error', truncateToWidth(` ● ${line}`, width)), anchor);
1284
1452
  }
1285
1453
  return;
1286
1454
  }
@@ -2069,6 +2237,15 @@ export class SshTui {
2069
2237
  onRtt: (rttMs) => {
2070
2238
  this.applyProbedRtt(rttMs);
2071
2239
  },
2240
+ onMetrics: metrics => {
2241
+ // The relay measured the terminal this session is displayed on. Adopt it
2242
+ // and repaint from scratch: every cached line was measured with the old
2243
+ // table, so a diff against them would compare different widths.
2244
+ setAmbiguousWidthMeasured(metrics.wide);
2245
+ setAmbiguousWidthReserve(metrics.reserve);
2246
+ this.forceFullPaint = true;
2247
+ this.markDirty();
2248
+ },
2072
2249
  onDetach: (info) => {
2073
2250
  this.handleDisplayDetach(info);
2074
2251
  },
@@ -3210,7 +3387,74 @@ export class SshTui {
3210
3387
  && this.rows.length > this.paintTailBudget + 8;
3211
3388
  const historyStart = skipMiddle ? Math.max(0, this.rows.length - this.paintTailBudget) : 0;
3212
3389
  let paintedLeadingCompact = skipMiddle;
3390
+ // Per-row render cache. A row whose fingerprint is unchanged replays its
3391
+ // lines instead of re-rendering them, which is what turns a frame from
3392
+ // "parse every markdown block in the transcript" into "parse the one that
3393
+ // changed". The base key covers the state every row reads (width, view mode,
3394
+ // focus, search, theme, ascii fallback); the row key covers its own fields.
3395
+ const displayBase = this.displayBaseKey(width);
3396
+ // The row body has several `continue`s, so its cache entry is written when the
3397
+ // *next* row starts (and once after the loop) rather than at every exit.
3398
+ let pendingFinish;
3399
+ const flushPending = () => {
3400
+ const finish = pendingFinish;
3401
+ pendingFinish = undefined;
3402
+ finish?.();
3403
+ };
3404
+ // Two of the row body's inputs come from *other* rows: whether this is still
3405
+ // in the "leading" stretch of a compact view (which flips once an assistant
3406
+ // or user row has been painted) and whether the row is folded into a tool
3407
+ // burst rendered by the reply above it. Leaving them out of the cache key is
3408
+ // what put a second copy of a running card on screen — the stale entry
3409
+ // replayed lines for a row that now renders inside its reply's burst.
3410
+ let leadingPhase = skipMiddle;
3411
+ // One tick per frame, shared by every live row: coarse enough not to churn a
3412
+ // key mid-frame, fine enough that a spinner or an elapsed time moves.
3413
+ const liveTick = Math.floor(Date.now() / 200);
3213
3414
  for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
3415
+ flushPending();
3416
+ const cachedRow = this.rows[rowIndex];
3417
+ const inBurst = compact && cachedRow !== undefined && compactBurstByReply.has(cachedRow);
3418
+ let cacheKey;
3419
+ if (cachedRow !== undefined && !skipMiddle) {
3420
+ const live = SshTui.isLiveRow(cachedRow);
3421
+ // The burst belongs to this row's rendering even though its rows are
3422
+ // elsewhere in the transcript, so its content is part of the key.
3423
+ const burst = inBurst ? compactBurstByReply.get(cachedRow) : undefined;
3424
+ const leadingBurst = compact && !leadingPhase && (cachedRow.kind === 'assistant' || cachedRow.kind === 'user')
3425
+ ? compactBursts.find(candidate => candidate.after === undefined)
3426
+ : undefined;
3427
+ const burstKey = burst === undefined && leadingBurst === undefined
3428
+ ? '-'
3429
+ : `${burst === undefined ? '' : SshTui.burstKey(burst.groups)}|${leadingBurst === undefined ? '' : SshTui.burstKey(leadingBurst.groups)}`;
3430
+ cacheKey = `${displayBase}|${rowIndex === 4 ? 'fold' : ''}|${leadingPhase ? 'lead' : '-'}|${live ? `t${liveTick}` : 'settled'}|${burstKey}|${SshTui.rowKey(cachedRow)}`;
3431
+ const entry = this.displayRowCache.get(cachedRow);
3432
+ if (SshTui.rowRendersEqual(cachedRow, entry, cacheKey)) {
3433
+ for (const line of entry.lines)
3434
+ display.push(line);
3435
+ for (const ref of entry.refs)
3436
+ displayRefs.push(ref);
3437
+ for (const gutter of entry.gutters)
3438
+ displayGutters.push(gutter);
3439
+ // The body flips this flag when it paints an assistant/user row; the
3440
+ // replay has to do the same or the next row's key would lie.
3441
+ if (compact && (cachedRow.kind === 'assistant' || cachedRow.kind === 'user'))
3442
+ leadingPhase = true;
3443
+ continue;
3444
+ }
3445
+ }
3446
+ const markStart = { line: display.length, ref: displayRefs.length, gutter: displayGutters.length };
3447
+ const finishRow = () => {
3448
+ if (cacheKey === undefined || cachedRow === undefined)
3449
+ return;
3450
+ this.displayRowCache.set(cachedRow, {
3451
+ key: cacheKey,
3452
+ lines: display.slice(markStart.line),
3453
+ refs: displayRefs.slice(markStart.ref),
3454
+ gutters: displayGutters.slice(markStart.gutter),
3455
+ });
3456
+ };
3457
+ pendingFinish = finishRow;
3214
3458
  if (skipMiddle && rowIndex >= 4 && rowIndex < historyStart) {
3215
3459
  if (rowIndex === 4)
3216
3460
  addDisplay(this.styleLine('system', t('history.folded')));
@@ -3448,6 +3692,13 @@ export class SshTui {
3448
3692
  }
3449
3693
  }
3450
3694
  }
3695
+ // Close the last row's cache entry here, and only here: everything below
3696
+ // belongs to no row. Flushing after the live tail instead stored the tail in
3697
+ // the last row's entry, so every later frame replayed a copy of the wait card
3698
+ // at the elapsed time of the frame that stored it — a "processing" line
3699
+ // frozen at 0s next to the live one, and a new copy each time another row
3700
+ // became the last one.
3701
+ flushPending();
3451
3702
  if (this.streaming !== undefined) {
3452
3703
  if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
3453
3704
  const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
@@ -3700,7 +3951,9 @@ export class SshTui {
3700
3951
  const option = options[index];
3701
3952
  if (option === undefined)
3702
3953
  continue;
3703
- const marker = d.selected.has(index) ? '●' : '○';
3954
+ // `●` follows the cursor: it is the row Enter submits. Ticked rows in a
3955
+ // multi-select list show `✓` so both facts stay visible at once.
3956
+ const marker = questionOptionMarker(d, index);
3704
3957
  const key = QUESTION_OPTION_KEYS[index] ?? '↕';
3705
3958
  const focused = index === d.cursor ? '›' : ' ';
3706
3959
  const recommended = option.label === approve ? t('dialog.recommended') : '';
@@ -3751,7 +4004,9 @@ export class SshTui {
3751
4004
  suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
3752
4005
  }
3753
4006
  const promptPlain = this.color ? '❯ ' : '> ';
3754
- const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
4007
+ const prompt = this.color
4008
+ ? `\x1b[${themeExtraToken(this.theme, 'accent')}m${promptPlain.trimEnd()}\x1b[0m `
4009
+ : promptPlain;
3755
4010
  const promptWidth = displayWidth(promptPlain);
3756
4011
  const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
3757
4012
  const inputTextWidth = Math.max(1, width - promptWidth);
@@ -3922,11 +4177,11 @@ export class SshTui {
3922
4177
  let statusLine = this.styleLine('system', statusText);
3923
4178
  if (this.color && this.contextPressure !== undefined) {
3924
4179
  const ring = formatContextPressureRing(this.contextPressure.percent);
3925
- statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[90m`);
4180
+ statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[${themeToken(this.theme, 'system')}m`);
3926
4181
  }
3927
4182
  if (this.color) {
3928
4183
  const chip = identity.find(part => part.startsWith('sub:')) ?? '';
3929
- statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || '90', this.colorDepth);
4184
+ statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || themeToken(this.theme, 'system'), this.colorDepth);
3930
4185
  }
3931
4186
  // The strip: one loss order for the groups a narrow terminal can do without.
3932
4187
  // Health leads because it is the only group that reports a broken install;
@@ -4582,31 +4837,17 @@ export class SshTui {
4582
4837
  return code === '' ? ' │ ' : `\x1b[${code}m │ `;
4583
4838
  }
4584
4839
  mutedSgr() {
4585
- return this.color ? downgradeSgr('90', this.colorDepth) : '';
4840
+ return this.color ? downgradeSgr(themeToken(this.theme, 'system'), this.colorDepth) : '';
4586
4841
  }
4587
4842
  styleLine(kind, text) {
4588
4843
  const safe = sanitizeTerminalText(text);
4589
4844
  if (!this.color)
4590
4845
  return safe;
4591
- const requested = kind === 'user' ? '36' :
4592
- kind === 'assistant' ? '37' :
4593
- kind === 'reasoning' ? '2;3' :
4594
- kind === 'brand' ? '1;38;2;77;107;253' :
4595
- kind === 'tool' || kind === 'tool-result' ? '37' :
4596
- // Codex-like: muted add/del that blend into the terminal background.
4597
- kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
4598
- kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
4599
- kind === 'diff-path' ? '1;36' :
4600
- kind === 'todo-done' ? '2;32' :
4601
- kind === 'todo-active' ? '1;36' :
4602
- kind === 'todo-failed' ? '31' :
4603
- kind === 'todo-skipped' ? '2;33' :
4604
- kind === 'todo-pending' ? '90' :
4605
- kind === 'plan-dock' ? '38;5;180' :
4606
- kind === 'subagent-header' ? '38;5;141' :
4607
- kind === 'diag' ? '2;36' :
4608
- kind === 'error' ? '31' :
4609
- '90';
4846
+ // Roles, not colours: `theme.ts` owns the palette, this method owns the
4847
+ // escaping. Primary text and tool output carry an empty token on purpose —
4848
+ // they take the terminal's own foreground, which is the only choice that is
4849
+ // readable on both light and dark terminals.
4850
+ const requested = themeToken(this.theme, kind);
4610
4851
  const code = downgradeSgr(requested, this.colorDepth);
4611
4852
  // Nothing left to paint: the text stands on its own.
4612
4853
  if (code === '')
@@ -4624,7 +4865,7 @@ export class SshTui {
4624
4865
  * the inverse video stays for that case.
4625
4866
  */
4626
4867
  styleEmphasisedPiece(kind, text) {
4627
- const requested = DIFF_EMPHASIS_SGR[kind];
4868
+ const requested = themeEmphasisToken(this.theme, kind);
4628
4869
  if (requested !== undefined && this.color) {
4629
4870
  const code = downgradeSgr(requested, this.colorDepth);
4630
4871
  if (code !== '')
@@ -5053,11 +5294,17 @@ export class SshTui {
5053
5294
  if (this.hostApprovalPolicy === 'never')
5054
5295
  this.warnApprovalMismatch();
5055
5296
  break;
5297
+ case 'user/message':
5298
+ this.sawUserInput = true;
5299
+ break;
5056
5300
  case 'turn/start':
5057
5301
  this.stalledWarningShown = false;
5058
5302
  this.turnSawOutput = false;
5059
5303
  this.turnSawReasoning = false;
5060
5304
  this.llmRetry = undefined;
5305
+ // One automatic retry per user message, and only for the failure class
5306
+ // that is worth retrying (see `auth-failure.ts`).
5307
+ this.authRetryArmed = true;
5061
5308
  this.status = `turn ${event.data.turn} running`;
5062
5309
  // A turn is about to spend on this route: make sure the session's record
5063
5310
  // names it, whatever changed it (a preset, a settings edit, a resume).
@@ -5094,6 +5341,7 @@ export class SshTui {
5094
5341
  : `idle (${reason.kind})`;
5095
5342
  if (reason.kind === 'error') {
5096
5343
  this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
5344
+ this.reportAuthFailure(String(reason.error.message ?? ''));
5097
5345
  }
5098
5346
  if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
5099
5347
  // The provider ended the turn without an answer. Some gateways map a
@@ -6614,7 +6862,7 @@ export class SshTui {
6614
6862
  this.removeQueuedDialog(dialog);
6615
6863
  dialog.resolve('cancel');
6616
6864
  }
6617
- openQuestion(question, index, total, resolve, reject, preselected, matchKeys) {
6865
+ openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor) {
6618
6866
  // A list opens with its first option already chosen: Enter then answers that
6619
6867
  // default for a single- and a multi-select question alike, instead of
6620
6868
  // coming back with an empty selection. Esc stays the explicit cancel, and a
@@ -6623,14 +6871,26 @@ export class SshTui {
6623
6871
  const initial = preselected !== undefined && preselected >= 0 && preselected < optionCount
6624
6872
  ? preselected
6625
6873
  : optionCount > 0 ? 0 : -1;
6874
+ // A single-select list opens with its first option chosen, so Enter answers
6875
+ // that default (and moving the highlight moves the `●`).
6876
+ //
6877
+ // A **multi-select** list must open with nothing chosen. It used to inherit
6878
+ // the same default, and the result was the worst kind of mis-answer: the
6879
+ // reader moved the highlight to the row they wanted, pressed Enter, and got
6880
+ // the first row — because `●` still sat on it. `questionSubmit` already
6881
+ // falls back to the highlighted row when nothing is marked, which is exactly
6882
+ // the "this one" the reader meant; the inherited selection was what kept that
6883
+ // fallback from firing. The caller can still pass an explicit preselection.
6884
+ const initialSelection = initialQuestionSelection(question, preselected);
6626
6885
  const dialog = {
6627
6886
  kind: 'questions',
6628
6887
  question,
6629
6888
  index,
6630
6889
  total,
6631
- selected: new Set(initial >= 0 ? [initial] : []),
6890
+ selected: new Set(initialSelection),
6632
6891
  cursor: initial >= 0 ? initial : 0,
6633
6892
  ...(matchKeys === undefined ? {} : { matchKeys }),
6893
+ ...(onCursor === undefined ? {} : { onCursor }),
6634
6894
  resolve: (selection) => {
6635
6895
  this.settleQuestion(dialog, () => resolve(selection));
6636
6896
  },
@@ -6642,9 +6902,9 @@ export class SshTui {
6642
6902
  return dialog;
6643
6903
  }
6644
6904
  /** Open one question dialog and await its answer (cancellation rejects). */
6645
- askQuestion(question, index = 0, total = 1, preselected, matchKeys) {
6905
+ askQuestion(question, index = 0, total = 1, preselected, matchKeys, onCursor) {
6646
6906
  return new Promise((resolve, reject) => {
6647
- this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys);
6907
+ this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor);
6648
6908
  });
6649
6909
  }
6650
6910
  /** The stored llm-pi-ai profile for one provider route, when settings provide one. */
@@ -8034,6 +8294,221 @@ export class SshTui {
8034
8294
  this.markDirty();
8035
8295
  }
8036
8296
  /** /disconnect: pause (default) or continue the turn after SSH drop. */
8297
+ /**
8298
+ * Say where an auth failure came from, and retry it if the user asked for that.
8299
+ *
8300
+ * `code: "AUTH"` collapses two opposite situations (see `auth-failure.ts`), and
8301
+ * the host does not retry either of them: a provider-side 401/403 that lasted
8302
+ * 45 seconds killed two turns in a row on 2026-09-29 while the same key, model
8303
+ * and route answered 200 straight afterwards. The hint costs nothing and the
8304
+ * retry is opt-in, because resending a long session re-sends its whole context.
8305
+ */
8306
+ reportAuthFailure(message) {
8307
+ if (this.replaying)
8308
+ return;
8309
+ // The reasoning-replay 400 is an upstream bug with its own workarounds, so it
8310
+ // gets its own sentence rather than the auth advice.
8311
+ if (isReasoningReplayFailure(message)) {
8312
+ this.pushRow({ kind: 'system', text: t('auth.reasoningReplay') });
8313
+ this.markDirty();
8314
+ return;
8315
+ }
8316
+ const classified = classifyAuthFailure(message);
8317
+ if (classified === undefined)
8318
+ return;
8319
+ void this.explainAuthFailure(classified);
8320
+ }
8321
+ async explainAuthFailure(classified) {
8322
+ const provider = this.currentProviderId();
8323
+ const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
8324
+ const configured = (await this.resolveCredential(envRef)) !== undefined;
8325
+ if (classified.origin === 'provider' && configured) {
8326
+ this.pushRow({
8327
+ kind: 'system',
8328
+ text: t('auth.providerRejected', {
8329
+ env: envRef,
8330
+ status: classified.status === undefined ? '' : `HTTP ${classified.status}`,
8331
+ }),
8332
+ });
8333
+ }
8334
+ else {
8335
+ this.pushRow({ kind: 'system', text: t('auth.credentialMissing', { env: envRef }) });
8336
+ }
8337
+ const retryable = classified.origin === 'provider' && configured
8338
+ && this.retryProviderAuthEnabled() && this.authRetryArmed
8339
+ && this.lastUserText !== '' && this.agent.status !== 'running';
8340
+ if (!retryable) {
8341
+ this.markDirty();
8342
+ return;
8343
+ }
8344
+ this.authRetryArmed = false;
8345
+ const text = this.lastUserText;
8346
+ // Cleared first: the retry starts its own turn, and a second failure inside
8347
+ // it must not fire another attempt.
8348
+ this.lastUserText = '';
8349
+ this.pushRow({ kind: 'system', text: t('auth.retryOnce') });
8350
+ this.beginWait();
8351
+ this.agent.followup(createUserMessage({
8352
+ content: [{ type: 'text', text }],
8353
+ source: { kind: 'user' },
8354
+ }));
8355
+ this.markDirty();
8356
+ }
8357
+ /** Whether the opt-in retry is on (`/retryauth`, the settings form, or the env). */
8358
+ retryProviderAuthEnabled() {
8359
+ const raw = String(process.env.DSH_TUI_RETRY_PROVIDER_AUTH ?? '').trim().toLowerCase();
8360
+ if (['1', 'true', 'on', 'yes'].includes(raw))
8361
+ return true;
8362
+ if (['0', 'false', 'off', 'no'].includes(raw))
8363
+ return false;
8364
+ const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
8365
+ if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
8366
+ const value = saved.retryProviderAuth;
8367
+ if (typeof value === 'boolean')
8368
+ return value;
8369
+ const text = String(value ?? '').trim().toLowerCase();
8370
+ if (['1', 'true', 'on', 'yes'].includes(text))
8371
+ return true;
8372
+ if (['0', 'false', 'off', 'no'].includes(text))
8373
+ return false;
8374
+ }
8375
+ return false;
8376
+ }
8377
+ /** Whether anything the reader typed reached this session (used on exit). */
8378
+ sessionHadUserInput() {
8379
+ return this.sawUserInput;
8380
+ }
8381
+ /**
8382
+ * `/cleanup [--dry-run]` — delete sessions that never saw user input.
8383
+ *
8384
+ * Every fresh start creates a session, so quitting without typing leaves an
8385
+ * artifact behind. The picker hides those, but the web session list reads the
8386
+ * same files without that filter, which is how an unused session still shows
8387
+ * up in another profile's menu. This walks the whole history once and deletes
8388
+ * the blank ones (the listing prunes as it resolves, which is the same rule the
8389
+ * picker applies).
8390
+ */
8391
+ async runCleanupCommand(arg) {
8392
+ const dryRun = arg.trim() === '--dry-run';
8393
+ if (arg.trim() !== '' && !dryRun) {
8394
+ this.pushRow({ kind: 'error', text: t('cleanup.usage') });
8395
+ this.markDirty();
8396
+ return;
8397
+ }
8398
+ const persistence = this.ctx.get('sessionPersistence');
8399
+ if (persistence === undefined) {
8400
+ this.pushRow({ kind: 'error', text: t('cleanup.unavailable') });
8401
+ this.markDirty();
8402
+ return;
8403
+ }
8404
+ this.pushRow({ kind: 'system', text: t(dryRun ? 'cleanup.scanning' : 'cleanup.working') });
8405
+ this.markDirty();
8406
+ const result = await pruneBlankSessions(persistence, String(this.agent.session.id), { dryRun });
8407
+ this.pushRow({
8408
+ kind: 'system',
8409
+ text: t(dryRun ? 'cleanup.dryRun' : 'cleanup.done', {
8410
+ pruned: String(result.pruned),
8411
+ kept: String(result.kept),
8412
+ unreadable: String(result.unreadable),
8413
+ }),
8414
+ });
8415
+ this.markDirty();
8416
+ }
8417
+ /** The theme saved in the `ssh-tui` settings section, if any. */
8418
+ readThemeName() {
8419
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
8420
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
8421
+ return undefined;
8422
+ const value = raw.theme;
8423
+ const name = String(value ?? '').trim();
8424
+ return name === '' ? undefined : name;
8425
+ }
8426
+ /**
8427
+ * `/theme [name]` — list the palettes, or switch to one and remember it.
8428
+ *
8429
+ * Switching repaints from the row cache, so it costs one frame rather than a
8430
+ * re-render of the transcript; `mono` exists for terminals where colour is the
8431
+ * problem rather than the answer, and it keeps every difference as an
8432
+ * attribute instead of throwing the difference away.
8433
+ */
8434
+ async runThemeCommand(arg) {
8435
+ const wanted = arg.trim().toLowerCase();
8436
+ if (wanted === '') {
8437
+ // A picker, not a list: ↑/↓ paints the candidate palette straight away, so
8438
+ // the reader judges it on their own transcript (and their own terminal)
8439
+ // before committing. Esc restores what they had — the same browse-preview-
8440
+ // revert flow Crush's theme picker offers, and the reason `onCursor` exists.
8441
+ const names = themeNames();
8442
+ const before = this.theme;
8443
+ const apply = (name) => {
8444
+ this.theme = setActiveTheme(name);
8445
+ // Every cached line carries a token from the old palette: repaint from
8446
+ // scratch rather than diffing against colours that no longer apply.
8447
+ this.forceFullPaint = true;
8448
+ this.markDirty();
8449
+ };
8450
+ let picked;
8451
+ try {
8452
+ const answer = await this.askQuestion({
8453
+ id: 'theme-pick',
8454
+ question: t('theme.pick'),
8455
+ options: names.map(name => ({ label: name, description: themeByName(name)?.name === before.name ? t('theme.currentTag') : '' })),
8456
+ }, 0, 1, Math.max(0, names.indexOf(before.name)), undefined, cursor => {
8457
+ const name = names[cursor];
8458
+ if (name !== undefined)
8459
+ apply(name);
8460
+ });
8461
+ picked = answer.selected[0];
8462
+ }
8463
+ catch {
8464
+ apply(before.name);
8465
+ this.pushRow({ kind: 'system', text: t('theme.cancelled', { name: before.name }) });
8466
+ this.markDirty();
8467
+ return;
8468
+ }
8469
+ const next = themeByName(picked) ?? before;
8470
+ apply(next.name);
8471
+ await this.mergeUiSettings({ theme: next.name });
8472
+ this.pushRow({ kind: 'system', text: t('theme.switched', { name: next.name }) });
8473
+ this.markDirty();
8474
+ return;
8475
+ }
8476
+ const next = themeByName(wanted);
8477
+ if (next === undefined) {
8478
+ this.pushRow({ kind: 'error', text: t('theme.unknown', { name: wanted, known: themeNames().join(', ') }) });
8479
+ this.markDirty();
8480
+ return;
8481
+ }
8482
+ this.theme = setActiveTheme(next.name);
8483
+ await this.mergeUiSettings({ theme: next.name });
8484
+ // A theme change touches every cached line, so the whole frame is stale.
8485
+ this.forceFullPaint = true;
8486
+ this.pushRow({ kind: 'system', text: t('theme.switched', { name: next.name }) });
8487
+ this.markDirty();
8488
+ }
8489
+ /** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */
8490
+ async runRetryAuthCommand(arg) {
8491
+ const id = arg.trim().toLowerCase();
8492
+ if (id === '' || id === 'status') {
8493
+ this.pushRow({
8494
+ kind: 'system',
8495
+ text: t('retryauth.status', {
8496
+ state: this.retryProviderAuthEnabled() ? t('retryauth.on') : t('retryauth.off'),
8497
+ }),
8498
+ });
8499
+ this.markDirty();
8500
+ return;
8501
+ }
8502
+ if (!['on', 'off'].includes(id)) {
8503
+ this.pushRow({ kind: 'error', text: t('retryauth.usage') });
8504
+ this.markDirty();
8505
+ return;
8506
+ }
8507
+ const on = id === 'on';
8508
+ await this.mergeUiSettings({ retryProviderAuth: on });
8509
+ this.pushRow({ kind: 'system', text: t('retryauth.switched', { state: on ? t('retryauth.on') : t('retryauth.off') }) });
8510
+ this.markDirty();
8511
+ }
8037
8512
  async runDisconnectCommand(arg) {
8038
8513
  const direct = parseDisconnectPolicy(arg);
8039
8514
  let next = direct;
@@ -10401,6 +10876,8 @@ export class SshTui {
10401
10876
  this.history.push(text);
10402
10877
  this.historyIndex = this.history.length;
10403
10878
  this.historyDraft = '';
10879
+ this.sawUserInput = true;
10880
+ this.lastUserText = text;
10404
10881
  this.input = '';
10405
10882
  this.cursor = 0;
10406
10883
  this.inputFolded = false;
@@ -10563,6 +11040,24 @@ export class SshTui {
10563
11040
  this.markDirty();
10564
11041
  });
10565
11042
  break;
11043
+ case 'cleanup':
11044
+ void this.runCleanupCommand(arg).catch((error) => {
11045
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'cleanup', error: errorChain(error) }) });
11046
+ this.markDirty();
11047
+ });
11048
+ break;
11049
+ case 'theme':
11050
+ void this.runThemeCommand(arg).catch((error) => {
11051
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'theme', error: errorChain(error) }) });
11052
+ this.markDirty();
11053
+ });
11054
+ break;
11055
+ case 'retryauth':
11056
+ void this.runRetryAuthCommand(arg).catch((error) => {
11057
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'retryauth', error: errorChain(error) }) });
11058
+ this.markDirty();
11059
+ });
11060
+ break;
10566
11061
  case 'copy':
10567
11062
  this.copyFocusedCard(arg);
10568
11063
  break;
@@ -11018,6 +11513,9 @@ export function mountTui(ctx, config) {
11018
11513
  disconnectPolicy() {
11019
11514
  return controller?.currentDisconnectPolicy() ?? 'pause';
11020
11515
  },
11516
+ sessionHadUserInput() {
11517
+ return controller?.sessionHadUserInput() ?? false;
11518
+ },
11021
11519
  };
11022
11520
  }
11023
11521
  //# sourceMappingURL=tui.js.map