dsh-ssh-tui 0.7.3 → 0.8.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 (78) hide show
  1. package/README.en.md +101 -27
  2. package/README.md +83 -27
  3. package/docs/remote-ops.md +80 -1
  4. package/docs/terminals.md +17 -4
  5. package/docs/windows.md +128 -0
  6. package/lib/attach.js +14 -1
  7. package/lib/attach.js.map +1 -1
  8. package/lib/auth-failure.js +92 -0
  9. package/lib/auth-failure.js.map +1 -0
  10. package/lib/commands.js +2 -0
  11. package/lib/commands.js.map +1 -1
  12. package/lib/copy-text.js +2 -0
  13. package/lib/copy-text.js.map +1 -1
  14. package/lib/diag.js +3 -0
  15. package/lib/diag.js.map +1 -1
  16. package/lib/dialogs.js.map +1 -1
  17. package/lib/display-sock.js +313 -2
  18. package/lib/display-sock.js.map +1 -1
  19. package/lib/dsh-compat.js +46 -0
  20. package/lib/dsh-compat.js.map +1 -1
  21. package/lib/i18n/en.js +49 -14
  22. package/lib/i18n/en.js.map +1 -1
  23. package/lib/i18n/index.js +5 -0
  24. package/lib/i18n/index.js.map +1 -1
  25. package/lib/i18n/zh.js +49 -14
  26. package/lib/i18n/zh.js.map +1 -1
  27. package/lib/index.js +76 -15
  28. package/lib/index.js.map +1 -1
  29. package/lib/line-mode.js +4 -0
  30. package/lib/line-mode.js.map +1 -1
  31. package/lib/paint.js +32 -1
  32. package/lib/paint.js.map +1 -1
  33. package/lib/picker.js +104 -15
  34. package/lib/picker.js.map +1 -1
  35. package/lib/plan.js +8 -0
  36. package/lib/plan.js.map +1 -1
  37. package/lib/platform.js +81 -0
  38. package/lib/platform.js.map +1 -1
  39. package/lib/preset-rows.js +54 -24
  40. package/lib/preset-rows.js.map +1 -1
  41. package/lib/question-wait.js +418 -0
  42. package/lib/question-wait.js.map +1 -0
  43. package/lib/selection.js +26 -8
  44. package/lib/selection.js.map +1 -1
  45. package/lib/term-text.js +79 -8
  46. package/lib/term-text.js.map +1 -1
  47. package/lib/terminal-input.js +13 -0
  48. package/lib/terminal-input.js.map +1 -1
  49. package/lib/tui.js +713 -51
  50. package/lib/tui.js.map +1 -1
  51. package/lib/types/attach.d.ts +8 -0
  52. package/lib/types/auth-failure.d.ts +48 -0
  53. package/lib/types/commands.d.ts +6 -0
  54. package/lib/types/diag.d.ts +2 -0
  55. package/lib/types/dialogs.d.ts +16 -0
  56. package/lib/types/display-sock.d.ts +78 -2
  57. package/lib/types/dsh-compat.d.ts +44 -12
  58. package/lib/types/i18n/index.d.ts +13 -3
  59. package/lib/types/index.d.ts +7 -0
  60. package/lib/types/paint.d.ts +21 -0
  61. package/lib/types/picker.d.ts +22 -0
  62. package/lib/types/platform.d.ts +22 -0
  63. package/lib/types/preset-rows.d.ts +18 -10
  64. package/lib/types/question-wait.d.ts +221 -0
  65. package/lib/types/selection.d.ts +12 -4
  66. package/lib/types/settings-subagent.d.ts +3 -3
  67. package/lib/types/subagent-model.d.ts +3 -3
  68. package/lib/types/term-text.d.ts +14 -0
  69. package/lib/types/terminal-input.d.ts +10 -0
  70. package/lib/types/transcript-types.d.ts +22 -0
  71. package/lib/types/tui.d.ts +149 -3
  72. package/lib/types/update-check.d.ts +19 -4
  73. package/lib/types/workspace-changes.d.ts +135 -0
  74. package/lib/update-check.js +25 -8
  75. package/lib/update-check.js.map +1 -1
  76. package/lib/workspace-changes.js +120 -0
  77. package/lib/workspace-changes.js.map +1 -0
  78. package/package.json +104 -69
package/lib/tui.js CHANGED
@@ -26,7 +26,7 @@ import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
26
26
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
27
27
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
28
28
  import { SessionId } from '@deepseek-ai/dsh-session';
29
- import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, } from './dsh-compat.js';
29
+ import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, TUI_SOURCE_KIND, } from './dsh-compat.js';
30
30
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
31
31
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
32
32
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
@@ -38,6 +38,7 @@ import { collectDoctor, doctorChecks, formatDoctorReport, rowsToRepair } from '.
38
38
  import { colorDepth, downgradeSgr } from './color-depth.js';
39
39
  import { appendRow, lineModeEnabled, lineModeLines } from './line-mode.js';
40
40
  import { keymapReport, resolveKeymap } from './keymap.js';
41
+ import { classifyAuthFailure } from './auth-failure.js';
41
42
  import { presetLabel, profileFromArgv } from './preset-label.js';
42
43
  import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
43
44
  import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
@@ -59,10 +60,12 @@ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentMod
59
60
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
60
61
  import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
61
62
  import { displayToolName, subagentCourtesyName } from './job-label.js';
63
+ import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
64
+ import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
62
65
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
63
66
  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';
64
67
  import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
65
- 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, } from './paint.js';
68
+ 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';
66
69
  import { TerminalInputGuard } from './terminal-input.js';
67
70
  import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerSubagentForeign, paintFooterSubagentChip, footerStatsGroups, formatContextPressureChip, providerHasQuotaSurface, formatStatusReport, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
68
71
  import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
@@ -445,6 +448,25 @@ const DEEPSEEK_LOGO_VARIANTS = [
445
448
  ],
446
449
  },
447
450
  ];
451
+ /**
452
+ * What marks the row the card cursor is on.
453
+ *
454
+ * The card headers spell `▶ ` themselves; a reply is drawn by the markdown
455
+ * renderer, so its marker is prepended and its width is measured from this
456
+ * string (three cells, or two once the ASCII fallback has rewritten it).
457
+ */
458
+ const REPLY_FOCUS_MARKER = '▶ ';
459
+ /**
460
+ * The text an inspect overlay hands to the copy key: what the overlay shows.
461
+ *
462
+ * The overlays are the one place the copy key can be pressed without leaving
463
+ * them (a dialog covers the input line), so the body on screen is what "copy"
464
+ * means there — not the card row that opened it, which for a changes card is a
465
+ * file list and for a tool card a summary the reader has already scrolled past.
466
+ */
467
+ function inspectCopyText(lines) {
468
+ return lines.map(line => line.text).join('\n').replace(/\s+$/u, '');
469
+ }
448
470
  export function parseDisconnectPolicy(raw) {
449
471
  const id = raw.trim().toLowerCase();
450
472
  if (id === 'pause' || id === 'cancel' || id === '暂停')
@@ -626,6 +648,7 @@ export class SshTui {
626
648
  relayRows;
627
649
  onHangup;
628
650
  onReattach;
651
+ onDetach;
629
652
  renderTimer;
630
653
  decoder = new StringDecoder('utf8');
631
654
  color;
@@ -659,6 +682,14 @@ export class SshTui {
659
682
  commandSuggestions = [];
660
683
  suggestionIndex = 0;
661
684
  focusedRow = null;
685
+ /**
686
+ * The text of the last prompt the user sent, kept so an opted-in retry can
687
+ * send the same thing again after a provider-side auth failure. Cleared when
688
+ * the retry fires, which is what bounds it to one attempt per user message.
689
+ */
690
+ lastUserText = '';
691
+ /** Set on every turn/start; a retry consumes it. */
692
+ authRetryArmed = false;
662
693
  pendingMessages = new Map();
663
694
  lastActivity = Date.now();
664
695
  lastIdleCtrlCAt = 0;
@@ -728,6 +759,15 @@ export class SshTui {
728
759
  inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
729
760
  thinkingStartedAt;
730
761
  waitStartedAt;
762
+ /**
763
+ * Whether the running turn produced anything a person can read: reply text or
764
+ * a tool card. A turn that ends with thinking only is the upstream returning
765
+ * an empty stop, and painting 完成 for it reads as a finished answer — see
766
+ * the hint at `turn/end`.
767
+ */
768
+ turnSawOutput = false;
769
+ /** Whether the turn produced thinking, which the empty-stop hint names. */
770
+ turnSawReasoning = false;
731
771
  completionSignaled = false;
732
772
  replaying = false;
733
773
  /** Display-line budget for the first paint after resume; 0 = full transcript. */
@@ -781,10 +821,22 @@ export class SshTui {
781
821
  detachedDeniedCount = 0;
782
822
  /** Questions waiting for a display right now — their cards do not exist yet. */
783
823
  queuedQuestions = 0;
824
+ /** When the current detached wait began, so the reconnect can say how long. */
825
+ questionWaitSince;
784
826
  paintIntervalMs;
785
827
  paintLink = 'local';
828
+ /**
829
+ * Whether this process runs inside an SSH session, from the sshd environment.
830
+ *
831
+ * Distinct from `paintLink`, which `applyProbedRtt` pins to `'ssh'` for any
832
+ * relayed frame — a local window included. Only this flag says the capability
833
+ * table described a remote tty rather than the terminal the bytes reach.
834
+ */
835
+ sshSession = false;
786
836
  paintProbed = false;
787
837
  paintRttMs;
838
+ /** The last few measured round-trips; the chip and the budget use their median. */
839
+ paintRttHistory = [];
788
840
  sessionTitle = '';
789
841
  llmRetry;
790
842
  quotaSnapshot;
@@ -833,6 +885,7 @@ export class SshTui {
833
885
  this.onRouteSettled = config.onRouteSettled;
834
886
  this.onHangup = config.onHangup;
835
887
  this.onReattach = config.onReattach;
888
+ this.onDetach = config.onDetach;
836
889
  this.headlessDisplay = config.headlessDisplay === true;
837
890
  // Line mode: no frames at all, one appended line per event. The alternate
838
891
  // screen is what makes a screen reader and a `tee` lose events, so it stays
@@ -844,7 +897,13 @@ export class SshTui {
844
897
  this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
845
898
  this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
846
899
  this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
847
- this.paintLink = detectSshSession() ? 'ssh' : 'local';
900
+ // Whether this process is inside an SSH session, kept apart from
901
+ // `paintLink`: that one also becomes `'ssh'` whenever the launcher relayed
902
+ // the frame (see `applyProbedRtt`), which is true for a purely local window
903
+ // too. This field answers the narrower question the clipboard caveat needs —
904
+ // "is the terminal the capability table described the one the bytes reach".
905
+ this.sshSession = detectSshSession();
906
+ this.paintLink = this.sshSession ? 'ssh' : 'local';
848
907
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
849
908
  ssh: this.paintLink === 'ssh',
850
909
  });
@@ -1013,7 +1072,7 @@ export class SshTui {
1013
1072
  return;
1014
1073
  this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
1015
1074
  this.markDirty();
1016
- const result = await installPluginLatest(info.profile);
1075
+ const result = await installPluginLatest(info.profile, info.latest);
1017
1076
  if (this.disposed)
1018
1077
  return;
1019
1078
  if (result.ok) {
@@ -1687,7 +1746,7 @@ export class SshTui {
1687
1746
  /** `/fix <row>`: repair one named roster row of the profile patch. */
1688
1747
  async runFixCommand(arg) {
1689
1748
  const name = arg.trim().toLowerCase();
1690
- const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1749
+ const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || (candidate.name ?? '').toLowerCase() === name);
1691
1750
  if (row === undefined) {
1692
1751
  this.pushRow({
1693
1752
  kind: 'error',
@@ -1795,6 +1854,11 @@ export class SshTui {
1795
1854
  return true;
1796
1855
  if (this.compactionRunning())
1797
1856
  return true;
1857
+ // A question queued for an answer that has not come. The turn itself has
1858
+ // settled, so nothing else here counts it, and the idle exit would otherwise
1859
+ // kill the Host while it is the only thing still holding that question.
1860
+ if (this.queuedQuestions > 0)
1861
+ return true;
1798
1862
  return false;
1799
1863
  }
1800
1864
  /**
@@ -1831,6 +1895,13 @@ export class SshTui {
1831
1895
  this.reattachedDuringHangup = false;
1832
1896
  ignoreFurtherHangupSignals();
1833
1897
  this.detachDisplay();
1898
+ // Say "no display" now rather than after the cancel/flush below. The lock's
1899
+ // `attached` is what a resume reads to decide that another window is on this
1900
+ // session, and for the whole cancel window it was still set while the window
1901
+ // was already gone — so a reconnect inside that window was refused as
1902
+ // "already attached". A reattach during this hangup patches it back (and the
1903
+ // writes are serialized launcher-side, so the last one wins).
1904
+ await this.onDetach?.();
1834
1905
  const busy = this.isBusyForHangupKeepalive();
1835
1906
  const pauseTurn = this.disconnectPolicy !== 'continue';
1836
1907
  if (pauseTurn && this.agent.status === 'running') {
@@ -1855,6 +1926,11 @@ export class SshTui {
1855
1926
  return;
1856
1927
  }
1857
1928
  const keepHost = this.displayHost !== undefined && busy;
1929
+ if (!keepHost) {
1930
+ // The Host is about to exit, and a marker left behind would keep saying a
1931
+ // question is waiting after the process that held it is gone.
1932
+ void clearWaitingMarker(String(this.agent.id));
1933
+ }
1858
1934
  if (keepHost) {
1859
1935
  this.hangingUp = false;
1860
1936
  // A relay that arrived while this hangup was unwinding set
@@ -2044,6 +2120,9 @@ export class SshTui {
2044
2120
  parts.push(t('attach.awayDetached', { count: detached }));
2045
2121
  if (waiting > 0)
2046
2122
  parts.push(t('attach.awayWaiting', { count: waiting }));
2123
+ if (waiting > 0 && this.questionWaitSince !== undefined) {
2124
+ parts.push(t('attach.questionWaited', { waited: formatShortDuration(Date.now() - this.questionWaitSince) }));
2125
+ }
2047
2126
  if (parts.length === 0)
2048
2127
  return;
2049
2128
  this.pushRow({
@@ -2107,18 +2186,38 @@ export class SshTui {
2107
2186
  currentDisconnectPolicy() {
2108
2187
  return this.disconnectPolicy;
2109
2188
  }
2189
+ /** The paint cadence in force right now: the link tier the footer shows. */
2190
+ currentPaintIntervalMs() {
2191
+ return this.paintIntervalMs;
2192
+ }
2110
2193
  applyProbedRtt(rttMs) {
2111
2194
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2112
2195
  this.paintLink = 'ssh';
2113
- // A reattach whose probe missed its window reports "unknown"; the link has
2114
- // not changed, so dropping a measurement we already have just blanks the
2115
- // footer chip to four hollow circles. Keep it (the next attach re-measures).
2116
- if (rttMs === undefined && this.paintProbed && this.paintRttMs !== undefined)
2117
- return;
2118
- this.paintProbed = rttMs !== undefined;
2119
- this.paintRttMs = rttMs;
2196
+ if (rttMs !== undefined) {
2197
+ // The relay re-measures the link on its own cadence, so this is called
2198
+ // again and again over one session. The reported round-trip is the median
2199
+ // of the last few measurements: a single burst that reads like a
2200
+ // 2-second link must not pin the footer chip — and with it the paint
2201
+ // cadence and the per-frame byte budget — while two measurements that
2202
+ // agree move it at once. That is the fix for a link that was red from the
2203
+ // first second to the last: the first measurement is taken while the
2204
+ // session is still booting, and it used to be the only one ever taken.
2205
+ this.paintRttHistory.push(rttMs);
2206
+ if (this.paintRttHistory.length > RTT_HISTORY)
2207
+ this.paintRttHistory.shift();
2208
+ this.paintProbed = true;
2209
+ this.paintRttMs = medianRtt(this.paintRttHistory);
2210
+ }
2211
+ else if (!this.paintProbed) {
2212
+ // A probe that missed its window reports "unknown". A link that was never
2213
+ // measured (a pipe, a terminal that does not answer DSR) stays unknown and
2214
+ // keeps the unprobed wording; one that has a measurement keeps its number
2215
+ // rather than blanking the chip to four hollow circles after every
2216
+ // auto-reconnect.
2217
+ this.paintRttMs = undefined;
2218
+ }
2120
2219
  if (!(Number.isFinite(envOverride) && envOverride > 0)) {
2121
- this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
2220
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: this.paintRttMs });
2122
2221
  if (!this.lineMode)
2123
2222
  this.startRenderTimer();
2124
2223
  }
@@ -2195,7 +2294,8 @@ export class SshTui {
2195
2294
  || row.kind === 'plan'
2196
2295
  || row.kind === 'question'
2197
2296
  || row.kind === 'goal'
2198
- || row.kind === 'compaction');
2297
+ || row.kind === 'compaction'
2298
+ || row.kind === 'changes');
2199
2299
  for (const burst of compactToolBursts(this.rows)) {
2200
2300
  const callAnchor = burst.groups.calls.at(-1);
2201
2301
  const editAnchor = burst.groups.edits.at(-1);
@@ -2213,13 +2313,35 @@ export class SshTui {
2213
2313
  || row.kind === 'question'
2214
2314
  || row.kind === 'goal'
2215
2315
  || row.kind === 'compaction'
2216
- || row.kind === 'prompt');
2316
+ || row.kind === 'prompt'
2317
+ || row.kind === 'changes');
2217
2318
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2218
2319
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2219
2320
  rows.push(this.streamingReasoning);
2220
2321
  }
2221
2322
  return rows;
2222
2323
  }
2324
+ /**
2325
+ * Everything the card cursor walks, in transcript order: the collapsible
2326
+ * cards plus the model replies.
2327
+ *
2328
+ * Built from the two lists rather than from the rows alone, because one of the
2329
+ * collapsible entries is synthetic: the live thinking block exists only while a
2330
+ * turn streams and is never a member of `this.rows`. Filtering the rows (the
2331
+ * first version of this) silently dropped it out of the cursor walk — ↑ on a
2332
+ * turn that had only streamed thinking did nothing, and Enter could no longer
2333
+ * expand the card being written. It belongs at the end, which is where it is
2334
+ * painted, so anything not in the rows is appended.
2335
+ */
2336
+ focusRing() {
2337
+ const collapsible = this.collapsibleRows();
2338
+ const ring = this.rows.filter(row => row.kind === 'assistant' || collapsible.includes(row));
2339
+ const known = new Set(this.rows);
2340
+ for (const row of collapsible)
2341
+ if (!known.has(row))
2342
+ ring.push(row);
2343
+ return ring;
2344
+ }
2223
2345
  spinnerFrame(periodMs = 120) {
2224
2346
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
2225
2347
  }
@@ -2357,7 +2479,7 @@ export class SshTui {
2357
2479
  // instruction that used to paint as `❯ …`.
2358
2480
  const message = createUserMessage({
2359
2481
  content: [{ type: 'text', text }],
2360
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary: queued },
2482
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary: queued },
2361
2483
  });
2362
2484
  try {
2363
2485
  this.agent.followup(message);
@@ -2568,7 +2690,10 @@ export class SshTui {
2568
2690
  if (dialog === undefined || dialog.kind !== 'inspect')
2569
2691
  return;
2570
2692
  const header = this.styleLine('system', truncateToWidth(dialog.title, width));
2571
- const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
2693
+ // A copy from inside the overlay replaces the key legend for the rest of the
2694
+ // visit: the notice row it also pushes is behind this screen, and a copy the
2695
+ // reader cannot confirm is the one thing worse than no copy at all.
2696
+ const hint = this.styleLine('system', truncateToWidth(dialog.notice ?? t('tool.inspectHint'), width));
2572
2697
  const divider = this.styleLine('system', repeatToWidth('─', width));
2573
2698
  const bodyBudget = Math.max(1, height - 4);
2574
2699
  const rendered = [];
@@ -2645,6 +2770,7 @@ export class SshTui {
2645
2770
  title,
2646
2771
  lines,
2647
2772
  offset: 0,
2773
+ copyText: inspectCopyText(lines),
2648
2774
  });
2649
2775
  }
2650
2776
  openSubagentInspect(row) {
@@ -2652,6 +2778,8 @@ export class SshTui {
2652
2778
  if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
2653
2779
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
2654
2780
  dialog.lines = subagentInspectLines(row);
2781
+ dialog.copyText = inspectCopyText(dialog.lines);
2782
+ dialog.notice = undefined;
2655
2783
  this.markDirty();
2656
2784
  return;
2657
2785
  }
@@ -2665,8 +2793,36 @@ export class SshTui {
2665
2793
  lines,
2666
2794
  offset: 0,
2667
2795
  subagentSessionId: this.subagentInspectId(row),
2796
+ copyText: inspectCopyText(lines),
2668
2797
  });
2669
2798
  }
2799
+ /**
2800
+ * Read one reply full-screen.
2801
+ *
2802
+ * What Enter does on a selected reply: a reply has no body to fold away, and
2803
+ * the transcript wraps it to the window — which for a long answer with tables
2804
+ * or code blocks is not how it reads. The overlay is the same surface the tool
2805
+ * cards use, and the copy key works inside it, so "read it, then copy it"
2806
+ * needs no trip back through the timeline.
2807
+ */
2808
+ openReplyInspect(row) {
2809
+ // Line mode has no scroller, and `echoInspectToLog` would append the reply
2810
+ // to the log a second time — it is already there, in full. Nothing to open.
2811
+ if (this.lineMode)
2812
+ return;
2813
+ // `- 2`, matching `paintInspectOverlay`, which re-wraps every body line at
2814
+ // `width - 2` inside a two-space indent: rendered wider, long table and code
2815
+ // rows were re-wrapped mid-cell at narrow widths.
2816
+ const width = Math.max(20, this.screenColumns() - 2);
2817
+ // Plain: `styleLine` strips escapes, so a coloured render would lose its
2818
+ // styling anyway — and the overlay's own frame supplies the base style.
2819
+ const lines = renderMarkdownLines(row.text, width, false, false)
2820
+ .map(line => ({ kind: 'assistant', text: line }));
2821
+ const title = t('reply.inspectTitle', { lines: lines.length });
2822
+ if (this.echoInspectToLog(title, lines))
2823
+ return;
2824
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: row.text });
2825
+ }
2670
2826
  /**
2671
2827
  * Line mode has no framed scroller, so an inspect body goes into the log.
2672
2828
  *
@@ -2711,9 +2867,27 @@ export class SshTui {
2711
2867
  addDisplay(this.selectLine(styled, focused), row);
2712
2868
  }
2713
2869
  }
2714
- /** Move the expand/collapse focus among reasoning and tool rows. */
2715
- moveCollapsibleFocus(delta) {
2716
- const rows = this.collapsibleRows();
2870
+ /**
2871
+ * Which file line of an expanded changes card the screen cursor is on.
2872
+ *
2873
+ * The card paints its header, then one row per file, and `selectableLines`
2874
+ * holds exactly the viewport. The cursor sits on the input row unless the
2875
+ * reader scrolled, so the file is how many painted rows above the cursor the
2876
+ * card's header is. A cursor on the header, the remainder line, or the prompt
2877
+ * falls back to the first file, which is the only defensible guess.
2878
+ */
2879
+ changesFileIndex(row) {
2880
+ const painted = this.selectableLines.findIndex(line => line.ref === row);
2881
+ if (painted < 0)
2882
+ return 0;
2883
+ const index = this.lastPaintCursorRow - 1 - (this.lastTranscriptStart + painted);
2884
+ if (index < 1 || index > row.files.length)
2885
+ return 0;
2886
+ return index - 1;
2887
+ }
2888
+ /** Move the card cursor among the cards and the replies. */
2889
+ moveFocus(delta) {
2890
+ const rows = this.focusRing();
2717
2891
  if (rows.length === 0)
2718
2892
  return;
2719
2893
  if (this.focusedRow === null) {
@@ -2731,13 +2905,23 @@ export class SshTui {
2731
2905
  }
2732
2906
  /** Toggle the focused block; without focus, toggle the most recent one. */
2733
2907
  toggleCollapsible() {
2908
+ const focused = this.focusedRow;
2909
+ // A reply has no body to fold away, so Enter reads it full-screen instead.
2910
+ // Falling through to the lookup below would be worse than doing nothing:
2911
+ // a reply is not in `collapsibleRows()`, and the fallback is "the newest
2912
+ // card" — Enter on a selected reply would expand a card the reader never
2913
+ // picked.
2914
+ if (focused !== null && focused.kind === 'assistant') {
2915
+ this.openReplyInspect(focused);
2916
+ return;
2917
+ }
2734
2918
  const rows = this.collapsibleRows();
2735
2919
  if (rows.length === 0)
2736
2920
  return;
2737
- const focused = this.focusedRow !== null && rows.includes(this.focusedRow)
2738
- ? this.focusedRow
2921
+ const inRing = focused !== null && rows.includes(focused)
2922
+ ? focused
2739
2923
  : undefined;
2740
- const target = focused ?? rows[rows.length - 1];
2924
+ const target = inRing ?? rows[rows.length - 1];
2741
2925
  if (target === undefined)
2742
2926
  return;
2743
2927
  this.toggleCard(target);
@@ -2758,6 +2942,14 @@ export class SshTui {
2758
2942
  return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
2759
2943
  }
2760
2944
  toggleCard(target) {
2945
+ // A changes card opens its file list first; the next Enter reads the file
2946
+ // the cursor is on. With one file there is nothing to choose, so that Enter
2947
+ // opens it directly.
2948
+ if (target.kind === 'changes' && target.expanded && target.files.length > 0) {
2949
+ this.focusedRow = target;
2950
+ this.openChangesInspect(target, target.files.length === 1 ? 0 : this.changesFileIndex(target));
2951
+ return;
2952
+ }
2761
2953
  if (target.kind === 'subagent') {
2762
2954
  this.focusedRow = target;
2763
2955
  this.openSubagentInspect(target);
@@ -2782,11 +2974,14 @@ export class SshTui {
2782
2974
  const rows = this.collapsibleRows();
2783
2975
  if (rows.length === 0)
2784
2976
  return;
2977
+ // Ctrl+R acts on the cards; a selected reply is not one, and it must survive
2978
+ // the sweep either way (it was not collapsed, so it must not be deselected).
2979
+ const selected = this.focusedRow?.kind === 'assistant' ? this.focusedRow : undefined;
2785
2980
  const allExpanded = rows.every(row => row.expanded);
2786
2981
  if (allExpanded) {
2787
2982
  for (const row of rows)
2788
2983
  row.expanded = false;
2789
- this.focusedRow = null;
2984
+ this.focusedRow = selected ?? null;
2790
2985
  }
2791
2986
  else {
2792
2987
  const width = Math.max(10, this.screenColumns());
@@ -2802,7 +2997,7 @@ export class SshTui {
2802
2997
  }
2803
2998
  row.expanded = true;
2804
2999
  }
2805
- this.focusedRow = rows[rows.length - 1] ?? null;
3000
+ this.focusedRow = selected ?? rows[rows.length - 1] ?? null;
2806
3001
  }
2807
3002
  this.forceFullPaint = true;
2808
3003
  this.markDirty();
@@ -2866,6 +3061,14 @@ export class SshTui {
2866
3061
  this.focusedRow = row;
2867
3062
  this.forceFullPaint = true;
2868
3063
  }
3064
+ else if (row.kind === 'assistant') {
3065
+ // A reply has nothing to expand, but revealing it must leave it selected:
3066
+ // Alt+4 and `/find 回复` land here, and clearing the focus (which is what
3067
+ // this branch used to do) meant `/copy` afterwards only worked by falling
3068
+ // back to "the latest reply" — silently, and only when it happened to be
3069
+ // the same row.
3070
+ this.focusedRow = row;
3071
+ }
2869
3072
  else {
2870
3073
  this.focusedRow = null;
2871
3074
  }
@@ -2964,14 +3167,39 @@ export class SshTui {
2964
3167
  }
2965
3168
  const display = [];
2966
3169
  const displayRefs = [];
3170
+ // Cells at the start of each painted line that are chrome rather than text.
3171
+ // Only the focus marker has any, and the drag-selection needs to know how
3172
+ // many so a copy off the selected row does not paste the marker.
3173
+ const displayGutters = [];
2967
3174
  const searchHit = this.searchHits[this.searchIndex];
2968
- const addDisplay = (line, ref) => {
3175
+ const addDisplay = (line, ref, gutter = 0) => {
2969
3176
  display.push(clipAnsiToWidth(line, width));
2970
3177
  displayRefs.push(ref);
3178
+ displayGutters.push(gutter);
2971
3179
  };
2972
3180
  const pushRow = (kind, text, ref) => {
2973
3181
  if (kind === 'assistant') {
2974
- for (const line of renderMarkdownLines(text, width, this.color, this.terminalCaps.osc8)) {
3182
+ const focused = ref !== undefined && this.focusedRow === ref;
3183
+ // The same string the card headers use, so the ASCII fallback rewrites
3184
+ // both alike, and the width comes from it rather than from a constant
3185
+ // (in ASCII `▶` becomes `> `, and the marker is still three cells).
3186
+ const marker = sanitizeTerminalText(REPLY_FOCUS_MARKER);
3187
+ const gutter = displayWidth(marker);
3188
+ // A focused reply is rendered three cells narrower so the marker cannot
3189
+ // eat its last characters: painted at full width and then prefixed, the
3190
+ // tail of every one of its lines was clipped away silently (the card
3191
+ // headers avoid this by truncating to the same kind of margin).
3192
+ const inner = focused ? Math.max(1, width - gutter) : width;
3193
+ let placed = false;
3194
+ for (const line of renderMarkdownLines(text, inner, this.color, this.terminalCaps.osc8)) {
3195
+ // The marker goes on the first line that has something on it: a reply
3196
+ // starting with a blank line would otherwise carry a marker all by
3197
+ // itself, with the reader's selection nowhere near its text.
3198
+ if (focused && !placed && stripAnsi(line).trim() !== '') {
3199
+ addDisplay(`${marker}${line}`, ref, gutter);
3200
+ placed = true;
3201
+ continue;
3202
+ }
2975
3203
  addDisplay(line, ref);
2976
3204
  }
2977
3205
  return;
@@ -3188,6 +3416,18 @@ export class SshTui {
3188
3416
  }
3189
3417
  continue;
3190
3418
  }
3419
+ if (row.kind === 'changes') {
3420
+ const header = `${row.header}${row.expanded ? '' : t('card.expand')}`;
3421
+ this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width);
3422
+ if (row.expanded) {
3423
+ for (const file of row.files) {
3424
+ addDisplay(this.styleLine('tool-result', ` ${file}`), row);
3425
+ }
3426
+ if (row.more !== undefined)
3427
+ addDisplay(this.styleLine('system', ` ${row.more}`), row);
3428
+ }
3429
+ continue;
3430
+ }
3191
3431
  if (row.kind === 'compaction') {
3192
3432
  const running = row.status === 'running';
3193
3433
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
@@ -3575,6 +3815,10 @@ export class SshTui {
3575
3815
  const start = window.start;
3576
3816
  const visible = window.visibleLines;
3577
3817
  const visibleRefs = window.visibleRefs;
3818
+ // `padding` blank rows are unshifted when the transcript is shorter than the
3819
+ // window, so the gutter of a visible line is read off the same offset the
3820
+ // window itself used (negative before the transcript starts → 0).
3821
+ const visibleGutters = visible.map((_, index) => displayGutters[start + index - window.padding] ?? 0);
3578
3822
  this.clickableRows.clear();
3579
3823
  this.paintedLinkHitsByRow.clear();
3580
3824
  this.transcriptTopScreenY = headerLines.length + 1;
@@ -3586,6 +3830,7 @@ export class SshTui {
3586
3830
  // Only a model reply is freely copyable; a tool card, a notice or the
3587
3831
  // chrome keeps its click behavior instead.
3588
3832
  copyable: visibleRefs[index]?.kind === 'assistant',
3833
+ gutter: visibleGutters[index] ?? 0,
3589
3834
  }));
3590
3835
  for (let index = 0; index < visibleRefs.length; index++) {
3591
3836
  const ref = visibleRefs[index];
@@ -4533,6 +4778,13 @@ export class SshTui {
4533
4778
  this.lastActivity = Date.now();
4534
4779
  if (!this.replaying)
4535
4780
  this.refreshContextPressure();
4781
+ // Before the switch, and by string: `workspace/changes` is not a key of the
4782
+ // 0.1.5 `SessionEventMap`, so a `case` for it fails strict compilation on
4783
+ // that tree. A Host without the service records nothing and this returns.
4784
+ if (String(event.type) === 'workspace/changes') {
4785
+ this.noteWorkspaceChanges(session.id, event.seq);
4786
+ return;
4787
+ }
4536
4788
  switch (event.type) {
4537
4789
  case 'user/message': {
4538
4790
  const text = event.data.content
@@ -4547,7 +4799,7 @@ export class SshTui {
4547
4799
  if (!this.replaying)
4548
4800
  this.beginWait();
4549
4801
  }
4550
- else if (sourceKind === 'plugin' && source.form === 'notice') {
4802
+ else if (source.form === 'notice') {
4551
4803
  const summary = source.summary?.trim() ?? '';
4552
4804
  // Body stays off the workspace (the model still received it).
4553
4805
  const last = this.rows.at(-1);
@@ -4564,12 +4816,17 @@ export class SshTui {
4564
4816
  if (!this.replaying)
4565
4817
  this.beginWait();
4566
4818
  }
4819
+ else if (source.form === 'snapshot') {
4820
+ // `form` is the ContextFormed discriminator every producer shares,
4821
+ // while `kind` is now per-producer (0.1.7 has no catch-all), so the
4822
+ // two content branches above key on the form: keying them on the
4823
+ // released `plugin` kind dropped a `time-context` snapshot — and
4824
+ // this plugin's own notices — into the raw-context row below.
4825
+ this.pushRow({ kind: 'system', text: text });
4826
+ }
4567
4827
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4568
4828
  this.pushPromptInjection(text, source.plugin);
4569
4829
  }
4570
- else if (sourceKind === 'plugin' && source.form === 'snapshot') {
4571
- this.pushRow({ kind: 'system', text: text });
4572
- }
4573
4830
  else {
4574
4831
  this.pushRow({ kind: 'system', text: t('prompt.contextPrefix', { text }) });
4575
4832
  }
@@ -4610,7 +4867,10 @@ export class SshTui {
4610
4867
  if (reasoning !== '') {
4611
4868
  this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
4612
4869
  }
4870
+ if (reasoning !== '')
4871
+ this.turnSawReasoning = true;
4613
4872
  if (text !== '') {
4873
+ this.turnSawOutput = true;
4614
4874
  this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
4615
4875
  }
4616
4876
  else if (interrupted && reasoning === '') {
@@ -4620,6 +4880,7 @@ export class SshTui {
4620
4880
  break;
4621
4881
  }
4622
4882
  case 'tool/call': {
4883
+ this.turnSawOutput = true;
4623
4884
  this.openToolCalls.set(String(event.data.callId), event.data.name);
4624
4885
  this.toolCallNames.set(String(event.data.callId), event.data.name);
4625
4886
  this.statsTracker.noteToolStart(String(event.data.callId), event.time);
@@ -4803,7 +5064,12 @@ export class SshTui {
4803
5064
  break;
4804
5065
  case 'turn/start':
4805
5066
  this.stalledWarningShown = false;
5067
+ this.turnSawOutput = false;
5068
+ this.turnSawReasoning = false;
4806
5069
  this.llmRetry = undefined;
5070
+ // One automatic retry per user message, and only for the failure class
5071
+ // that is worth retrying (see `auth-failure.ts`).
5072
+ this.authRetryArmed = true;
4807
5073
  this.status = `turn ${event.data.turn} running`;
4808
5074
  // A turn is about to spend on this route: make sure the session's record
4809
5075
  // names it, whatever changed it (a preset, a settings edit, a resume).
@@ -4840,6 +5106,18 @@ export class SshTui {
4840
5106
  : `idle (${reason.kind})`;
4841
5107
  if (reason.kind === 'error') {
4842
5108
  this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
5109
+ this.reportAuthFailure(String(reason.error.message ?? ''));
5110
+ }
5111
+ if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
5112
+ // The provider ended the turn without an answer. Some gateways map a
5113
+ // Gemini/Claude reply that only contains a thought part onto
5114
+ // `reasoning_content` and then finish with stop and no content, so the
5115
+ // turn looks completed while nothing was said and the user is left to
5116
+ // guess that another Enter is what continues it. Say so instead.
5117
+ this.pushRow({
5118
+ kind: 'system',
5119
+ text: t(this.turnSawReasoning ? 'turn.emptyThinkingOnly' : 'turn.emptyReply'),
5120
+ });
4843
5121
  }
4844
5122
  const livePlan = this.findLivePlanRow();
4845
5123
  if (livePlan !== undefined && reason.kind === 'completed') {
@@ -4926,6 +5204,100 @@ export class SshTui {
4926
5204
  this.status = 'disposed';
4927
5205
  this.markDirty();
4928
5206
  };
5207
+ /**
5208
+ * One `workspace/changes` event: the files a turn changed.
5209
+ *
5210
+ * The event carries only the turn number; the summary stays on the Host and
5211
+ * is served by `workspaceChanges` for this event's own sequence, and only
5212
+ * while the Session lives. A missing service (every 0.1.5 Host) or a summary
5213
+ * that can no longer be opened (a log replayed after a restart) shows
5214
+ * nothing — upstream's rule, so a card that cannot be read is never drawn.
5215
+ * A later event for the same turn replaces the earlier summary in place.
5216
+ */
5217
+ noteWorkspaceChanges(sessionId, seq) {
5218
+ if (typeof seq !== 'number')
5219
+ return;
5220
+ const service = workspaceChangesOf(this.ctx);
5221
+ if (service === undefined)
5222
+ return;
5223
+ const summary = service.summary(String(sessionId), seq);
5224
+ if (summary === undefined || !changesSummaryVisible(summary))
5225
+ return;
5226
+ const existing = this.rows.findLast((row) => row.kind === 'changes' && row.turn === summary.turn && row.sessionId === String(sessionId));
5227
+ if (existing !== undefined) {
5228
+ this.fillChangesCard(existing, summary, seq);
5229
+ }
5230
+ else {
5231
+ const row = {
5232
+ kind: 'changes',
5233
+ turn: summary.turn,
5234
+ seq,
5235
+ sessionId: String(sessionId),
5236
+ header: '',
5237
+ files: [],
5238
+ expanded: false,
5239
+ };
5240
+ this.fillChangesCard(row, summary, seq);
5241
+ this.pushRow(row);
5242
+ }
5243
+ this.markDirty();
5244
+ }
5245
+ /** Copy one summary onto its card. Shared by the first event and its replacements. */
5246
+ fillChangesCard(row, summary, seq) {
5247
+ row.seq = seq;
5248
+ row.header = changesHeader(summary);
5249
+ row.files = summary.files.map(changesFileLine);
5250
+ const more = changesRemainderLine(summary);
5251
+ if (more === undefined)
5252
+ delete row.more;
5253
+ else
5254
+ row.more = more;
5255
+ }
5256
+ /**
5257
+ * Full view of one file on a changes card: its comparison, as the service
5258
+ * computed it. Binary and oversized files have no lines, so the overlay
5259
+ * carries the one-line explanation instead of an empty body.
5260
+ */
5261
+ openChangesInspect(row, index) {
5262
+ const service = workspaceChangesOf(this.ctx);
5263
+ const label = row.files[index] ?? row.header;
5264
+ const title = t('changes.inspectTitle', { file: label });
5265
+ if (service === undefined) {
5266
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5267
+ return;
5268
+ }
5269
+ const controller = new AbortController();
5270
+ void service.diff(row.sessionId, row.seq, index, controller.signal)
5271
+ .then(diff => {
5272
+ const lines = renderChangesDiff(diff);
5273
+ this.openChangesInspectLines(title, lines.length > 0
5274
+ ? lines
5275
+ : [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5276
+ })
5277
+ .catch(() => {
5278
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5279
+ });
5280
+ }
5281
+ /** Open (or replace) the changes overlay, or echo it to the log in line mode. */
5282
+ openChangesInspectLines(title, lines) {
5283
+ if (this.echoInspectToLog(title, lines))
5284
+ return;
5285
+ const dialog = this.dialog;
5286
+ if (dialog?.kind === 'inspect') {
5287
+ dialog.title = title;
5288
+ dialog.lines = lines;
5289
+ dialog.offset = 0;
5290
+ // The dialog may be a leftover from another overlay, and this path is
5291
+ // asynchronous: a reply overlay opened while the diff was being read would
5292
+ // keep its `copyText`, so the copy key would hand back the reply under a
5293
+ // diff. Everything the previous body owned is replaced here.
5294
+ dialog.copyText = inspectCopyText(lines);
5295
+ dialog.notice = undefined;
5296
+ this.markDirty();
5297
+ return;
5298
+ }
5299
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: inspectCopyText(lines) });
5300
+ }
4929
5301
  /** Plan-mode / command / team events that plugins merge into SessionEventMap. */
4930
5302
  handleExtensionEvent(event) {
4931
5303
  const type = String(event.type);
@@ -5647,6 +6019,10 @@ export class SshTui {
5647
6019
  return;
5648
6020
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
5649
6021
  dialog.lines = subagentInspectLines(row);
6022
+ // The body grows while the child runs, and the copy text is that body: it
6023
+ // has to move with it or the key hands back the log as it stood when the
6024
+ // overlay opened.
6025
+ dialog.copyText = inspectCopyText(dialog.lines);
5650
6026
  }
5651
6027
  // ── approval and questions ──────────────────────────────────────────────
5652
6028
  hasLiveDisplay() {
@@ -5656,6 +6032,64 @@ export class SshTui {
5656
6032
  return this.displayHost?.attached === true;
5657
6033
  return true;
5658
6034
  }
6035
+ /**
6036
+ * Tell the absent user that a question is waiting.
6037
+ *
6038
+ * The marker file is the part that needs no configuration: it sits next to the
6039
+ * session's stderr log, so logging back into the jump host shows it before the
6040
+ * TUI is open. The command is whatever the user pointed `ssh-tui.notify` at,
6041
+ * run once; it never blocks the wait and never reports its own failure.
6042
+ */
6043
+ announceWaitingQuestion(request) {
6044
+ const sessionId = String(this.agent.id);
6045
+ const question = request.questions
6046
+ .map(item => item.question.trim())
6047
+ .filter(text => text !== '')
6048
+ .join(' / ')
6049
+ .slice(0, 200);
6050
+ const since = this.questionWaitSince ?? Date.now();
6051
+ void writeWaitingMarker({
6052
+ sessionId,
6053
+ count: this.queuedQuestions,
6054
+ question,
6055
+ since,
6056
+ });
6057
+ const command = this.readNotifyCommand();
6058
+ if (command === undefined)
6059
+ return;
6060
+ const smtp = this.readNotifySmtp();
6061
+ const context = {
6062
+ sessionId,
6063
+ count: this.queuedQuestions,
6064
+ question,
6065
+ waitedMs: Date.now() - since,
6066
+ resumeCommand: `dsh --profile ${profileFromArgv()} --resume ${sessionId}`,
6067
+ ...(smtp.user === undefined ? {} : { smtpUser: smtp.user }),
6068
+ ...(smtp.password === undefined ? {} : { smtpPassword: smtp.password }),
6069
+ };
6070
+ void runNotify(command, context);
6071
+ }
6072
+ /** The SMTP account `/notify smtp` saved, when the target is authenticated. */
6073
+ readNotifySmtp() {
6074
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6075
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
6076
+ return {};
6077
+ const saved = raw;
6078
+ const user = typeof saved.notifySmtpUser === 'string' ? saved.notifySmtpUser : undefined;
6079
+ const password = typeof saved.notifySmtpPassword === 'string' ? saved.notifySmtpPassword : undefined;
6080
+ return {
6081
+ ...(user === undefined || user === '' ? {} : { user }),
6082
+ ...(password === undefined || password === '' ? {} : { password }),
6083
+ };
6084
+ }
6085
+ /** `DSH_TUI_NOTIFY`, else the saved `ssh-tui.notify`. Empty means off. */
6086
+ readNotifyCommand() {
6087
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6088
+ const saved = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
6089
+ ? raw.notify
6090
+ : undefined;
6091
+ return notifyCommand(process.env, typeof saved === 'string' ? saved : undefined);
6092
+ }
5659
6093
  waitForLiveDisplay(signal) {
5660
6094
  if (this.hasLiveDisplay())
5661
6095
  return Promise.resolve();
@@ -5792,7 +6226,7 @@ export class SshTui {
5792
6226
  ...(request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason }),
5793
6227
  ...(this.hostSandboxMode === undefined || this.hostSandboxMode.trim() === '' ? {} : { sandboxMode: this.hostSandboxMode }),
5794
6228
  }) }],
5795
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6229
+ source: { kind: TUI_SOURCE_KIND },
5796
6230
  })],
5797
6231
  system: reviewSystemPrompt(getLocale()),
5798
6232
  maxTokens: 400,
@@ -5874,6 +6308,10 @@ export class SshTui {
5874
6308
  * `the user rejected tool "bash"`. Steer a plugin notice with the real
5875
6309
  * classifier/reviewer reason. Summary matches the workspace decision row
5876
6310
  * so the handler does not paint the body twice.
6311
+ *
6312
+ * This notice is committed to the session, so it is exactly the injection a
6313
+ * V4 log refuses when it wears the released `plugin` wrapper — see
6314
+ * {@link TUI_SOURCE_KIND}.
5877
6315
  */
5878
6316
  tellModelApprovalDenied(command, reason, summary) {
5879
6317
  if (this.replaying || this.agentGone)
@@ -5881,7 +6319,7 @@ export class SshTui {
5881
6319
  const text = t('approval.modelDenied', { command, reason });
5882
6320
  const message = createUserMessage({
5883
6321
  content: [{ type: 'text', text }],
5884
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary },
6322
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary },
5885
6323
  });
5886
6324
  try {
5887
6325
  if (this.agent.status === 'running')
@@ -5991,11 +6429,20 @@ export class SshTui {
5991
6429
  // Counted here, not read off the rows: the card is built only after the
5992
6430
  // wait resolves, so at reattach time a queued question has no row yet.
5993
6431
  this.queuedQuestions += 1;
6432
+ this.questionWaitSince ??= Date.now();
6433
+ // The user learns about the wait from a marker file and, when they
6434
+ // configured one, a command. Both run after the counter moves, so a
6435
+ // reconnect during either still sees the question as queued.
6436
+ this.announceWaitingQuestion(request);
5994
6437
  try {
5995
6438
  await this.waitForLiveDisplay(request.signal);
5996
6439
  }
5997
6440
  finally {
5998
6441
  this.queuedQuestions -= 1;
6442
+ if (this.queuedQuestions === 0) {
6443
+ this.questionWaitSince = undefined;
6444
+ void clearWaitingMarker(String(this.agent.id));
6445
+ }
5999
6446
  }
6000
6447
  }
6001
6448
  const answers = [];
@@ -7561,7 +8008,141 @@ export class SshTui {
7561
8008
  this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
7562
8009
  this.markDirty();
7563
8010
  }
8011
+ /**
8012
+ * /notify: where a question waiting on an absent user is announced.
8013
+ *
8014
+ * No argument shows what is configured. `off` clears it. `mail`, `smtp` and
8015
+ * `local` store the command that target needs; the SMTP password is kept
8016
+ * beside it and handed to the command through the environment, so it is never
8017
+ * part of the command string.
8018
+ */
8019
+ async runNotifyCommand(arg) {
8020
+ const target = parseNotifyTarget(arg);
8021
+ if (target === undefined) {
8022
+ this.pushRow({ kind: 'error', text: t('notify.usage') });
8023
+ this.markDirty();
8024
+ return;
8025
+ }
8026
+ if (target.kind === 'off' && arg.trim() === '') {
8027
+ const current = this.readNotifyCommand();
8028
+ this.pushRow({
8029
+ kind: 'system',
8030
+ text: current === undefined ? t('notify.statusOff') : t('notify.statusOn', { command: current }),
8031
+ });
8032
+ this.markDirty();
8033
+ return;
8034
+ }
8035
+ if (target.kind === 'off') {
8036
+ await this.mergeUiSettings({ notify: '', notifySmtpUser: '', notifySmtpPassword: '' });
8037
+ this.pushRow({ kind: 'system', text: t('notify.cleared') });
8038
+ this.markDirty();
8039
+ return;
8040
+ }
8041
+ await this.mergeUiSettings({
8042
+ notify: notifyTargetCommand(target),
8043
+ notifySmtpUser: target.kind === 'smtp' ? target.user ?? '' : '',
8044
+ notifySmtpPassword: target.kind === 'smtp' ? target.password ?? '' : '',
8045
+ });
8046
+ this.pushRow({ kind: 'system', text: t('notify.saved', { target: notifyTargetLabel(target) }) });
8047
+ this.markDirty();
8048
+ }
7564
8049
  /** /disconnect: pause (default) or continue the turn after SSH drop. */
8050
+ /**
8051
+ * Say where an auth failure came from, and retry it if the user asked for that.
8052
+ *
8053
+ * `code: "AUTH"` collapses two opposite situations (see `auth-failure.ts`), and
8054
+ * the host does not retry either of them: a provider-side 401/403 that lasted
8055
+ * 45 seconds killed two turns in a row on 2026-09-29 while the same key, model
8056
+ * and route answered 200 straight afterwards. The hint costs nothing and the
8057
+ * retry is opt-in, because resending a long session re-sends its whole context.
8058
+ */
8059
+ reportAuthFailure(message) {
8060
+ if (this.replaying)
8061
+ return;
8062
+ const classified = classifyAuthFailure(message);
8063
+ if (classified === undefined)
8064
+ return;
8065
+ void this.explainAuthFailure(classified);
8066
+ }
8067
+ async explainAuthFailure(classified) {
8068
+ const provider = this.currentProviderId();
8069
+ const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
8070
+ const configured = (await this.resolveCredential(envRef)) !== undefined;
8071
+ if (classified.origin === 'provider' && configured) {
8072
+ this.pushRow({
8073
+ kind: 'system',
8074
+ text: t('auth.providerRejected', {
8075
+ env: envRef,
8076
+ status: classified.status === undefined ? '' : `HTTP ${classified.status}`,
8077
+ }),
8078
+ });
8079
+ }
8080
+ else {
8081
+ this.pushRow({ kind: 'system', text: t('auth.credentialMissing', { env: envRef }) });
8082
+ }
8083
+ const retryable = classified.origin === 'provider' && configured
8084
+ && this.retryProviderAuthEnabled() && this.authRetryArmed
8085
+ && this.lastUserText !== '' && this.agent.status !== 'running';
8086
+ if (!retryable) {
8087
+ this.markDirty();
8088
+ return;
8089
+ }
8090
+ this.authRetryArmed = false;
8091
+ const text = this.lastUserText;
8092
+ // Cleared first: the retry starts its own turn, and a second failure inside
8093
+ // it must not fire another attempt.
8094
+ this.lastUserText = '';
8095
+ this.pushRow({ kind: 'system', text: t('auth.retryOnce') });
8096
+ this.beginWait();
8097
+ this.agent.followup(createUserMessage({
8098
+ content: [{ type: 'text', text }],
8099
+ source: { kind: 'user' },
8100
+ }));
8101
+ this.markDirty();
8102
+ }
8103
+ /** Whether the opt-in retry is on (`/retryauth`, the settings form, or the env). */
8104
+ retryProviderAuthEnabled() {
8105
+ const raw = String(process.env.DSH_TUI_RETRY_PROVIDER_AUTH ?? '').trim().toLowerCase();
8106
+ if (['1', 'true', 'on', 'yes'].includes(raw))
8107
+ return true;
8108
+ if (['0', 'false', 'off', 'no'].includes(raw))
8109
+ return false;
8110
+ const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
8111
+ if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
8112
+ const value = saved.retryProviderAuth;
8113
+ if (typeof value === 'boolean')
8114
+ return value;
8115
+ const text = String(value ?? '').trim().toLowerCase();
8116
+ if (['1', 'true', 'on', 'yes'].includes(text))
8117
+ return true;
8118
+ if (['0', 'false', 'off', 'no'].includes(text))
8119
+ return false;
8120
+ }
8121
+ return false;
8122
+ }
8123
+ /** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */
8124
+ async runRetryAuthCommand(arg) {
8125
+ const id = arg.trim().toLowerCase();
8126
+ if (id === '' || id === 'status') {
8127
+ this.pushRow({
8128
+ kind: 'system',
8129
+ text: t('retryauth.status', {
8130
+ state: this.retryProviderAuthEnabled() ? t('retryauth.on') : t('retryauth.off'),
8131
+ }),
8132
+ });
8133
+ this.markDirty();
8134
+ return;
8135
+ }
8136
+ if (!['on', 'off'].includes(id)) {
8137
+ this.pushRow({ kind: 'error', text: t('retryauth.usage') });
8138
+ this.markDirty();
8139
+ return;
8140
+ }
8141
+ const on = id === 'on';
8142
+ await this.mergeUiSettings({ retryProviderAuth: on });
8143
+ this.pushRow({ kind: 'system', text: t('retryauth.switched', { state: on ? t('retryauth.on') : t('retryauth.off') }) });
8144
+ this.markDirty();
8145
+ }
7565
8146
  async runDisconnectCommand(arg) {
7566
8147
  const direct = parseDisconnectPolicy(arg);
7567
8148
  let next = direct;
@@ -8104,13 +8685,21 @@ export class SshTui {
8104
8685
  // routing so a plain control byte reaches it as readily as a CSI sequence;
8105
8686
  // everything else keeps the handling below, which is what makes the defaults
8106
8687
  // unchanged by construction.
8107
- if (this.dialog === undefined && combined !== '') {
8108
- const moved = this.keymap.sequences.get(combined);
8688
+ const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
8689
+ // While a dialog is open its own routing owns the keys — with one exception:
8690
+ // an inspect overlay is the only place the copy key can be pressed at all,
8691
+ // and gating it put the reader's rebound key out of reach exactly where the
8692
+ // overlay's own hint advertises copying.
8693
+ const overlayCopy = this.dialog !== undefined
8694
+ && this.dialog.kind === 'inspect'
8695
+ && (this.dialog.copyText ?? '') !== ''
8696
+ && moved === 'copy';
8697
+ if ((this.dialog === undefined || overlayCopy) && combined !== '') {
8109
8698
  if (moved !== undefined && this.keymap.overridden.has(moved)) {
8110
8699
  if (this.runKeyAction(moved))
8111
8700
  return;
8112
8701
  }
8113
- if (this.keymap.suppressed.has(combined))
8702
+ if (this.dialog === undefined && this.keymap.suppressed.has(combined))
8114
8703
  return;
8115
8704
  }
8116
8705
  const escape = /^\x1b\[([A-D])$/u;
@@ -8135,7 +8724,7 @@ export class SshTui {
8135
8724
  this.markDirty();
8136
8725
  }
8137
8726
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8138
- this.moveCollapsibleFocus(-1);
8727
+ this.moveFocus(-1);
8139
8728
  }
8140
8729
  else {
8141
8730
  this.historyBack();
@@ -8159,7 +8748,7 @@ export class SshTui {
8159
8748
  this.markDirty();
8160
8749
  }
8161
8750
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8162
- this.moveCollapsibleFocus(1);
8751
+ this.moveFocus(1);
8163
8752
  }
8164
8753
  else {
8165
8754
  this.historyForward();
@@ -8187,19 +8776,23 @@ export class SshTui {
8187
8776
  this.scrollInspectOrTranscript(-3);
8188
8777
  return;
8189
8778
  }
8190
- // 32 is "motion with the left button held": a drag over a reply.
8191
- if (button === 32) {
8779
+ // 32 is "motion with the left button held": a drag over a reply. Shift
8780
+ // adds 4 to every button code, and a terminal that still forwards the
8781
+ // report while Shift is down (instead of keeping it for its own
8782
+ // selection) must drag exactly like an unshifted one — otherwise the
8783
+ // gesture the user was told to fall back on does nothing here.
8784
+ if (button === 32 || button === 36) {
8192
8785
  this.extendMouseSelection(y, x);
8193
8786
  return;
8194
8787
  }
8195
- if (button === 0) {
8788
+ if (button === 0 || button === 4) {
8196
8789
  this.beginMouseSelection(y, x);
8197
8790
  return;
8198
8791
  }
8199
8792
  return;
8200
8793
  }
8201
8794
  // A release ends either a drag (copy what it covered) or a plain click.
8202
- if (button === 0)
8795
+ if (button === 0 || button === 4)
8203
8796
  this.endMouseSelection(y, x);
8204
8797
  return;
8205
8798
  }
@@ -8432,13 +9025,20 @@ export class SshTui {
8432
9025
  this.input = this.input.slice(0, this.cursor);
8433
9026
  this.markDirty();
8434
9027
  return;
9028
+ // A dialog owns the keyboard while it is up: moving the transcript cursor
9029
+ // under it changed what a later copy would take while the screen showed
9030
+ // nothing of the sort.
8435
9031
  case '\x0e':
8436
- this.moveCollapsibleFocus(1);
9032
+ if (this.dialog === undefined)
9033
+ this.moveFocus(1);
8437
9034
  return;
8438
9035
  case '\x10':
8439
- this.moveCollapsibleFocus(-1);
9036
+ if (this.dialog === undefined)
9037
+ this.moveFocus(-1);
8440
9038
  return;
8441
9039
  case '\x12':
9040
+ if (this.dialog !== undefined)
9041
+ return;
8442
9042
  if (this.focusedRow === null)
8443
9043
  this.toggleCollapsible();
8444
9044
  else
@@ -9612,7 +10212,11 @@ export class SshTui {
9612
10212
  }
9613
10213
  /** The painted lines as plain text, for the selection rules. */
9614
10214
  plainSelectableLines() {
9615
- return this.selectableLines.map(line => ({ text: stripAnsi(line.raw), copyable: line.copyable }));
10215
+ return this.selectableLines.map(line => ({
10216
+ text: stripAnsi(line.raw),
10217
+ copyable: line.copyable,
10218
+ gutter: line.gutter,
10219
+ }));
9616
10220
  }
9617
10221
  /** Screen row from a mouse report to an index into `selectableLines`. */
9618
10222
  selectableLineAt(screenY) {
@@ -9737,7 +10341,10 @@ export class SshTui {
9737
10341
  }
9738
10342
  copyPlainText(text, notice) {
9739
10343
  this.copyYank = text;
9740
- this.focusedRow = null;
10344
+ // The selection stays: the marker on the copied row is how the reader sees
10345
+ // *what* went to the clipboard, and a second press of the copy key must be
10346
+ // idempotent. Clearing it here made the next `/copy` silently fall back to
10347
+ // "the latest reply" — a different row, with nothing on screen to say so.
9741
10348
  this.input = '';
9742
10349
  this.cursor = 0;
9743
10350
  this.inputFolded = false;
@@ -9750,14 +10357,32 @@ export class SshTui {
9750
10357
  // how the terminals that *do* honour it work — but where the table does not
9751
10358
  // promise it the user is told once per session rather than at every copy, or
9752
10359
  // not at all until they paste into an empty clipboard.
9753
- if (!this.terminalCaps.osc52 && !this.osc52HintShown) {
10360
+ //
10361
+ // An SSH session is the exception the table cannot see. The classifier reads
10362
+ // the *remote* terminal (the tty on the far side of the connection, which is
10363
+ // usually a bare Linux console or an unnamed pty), while the bytes are
10364
+ // written to the *local* terminal — the one the user is actually copying
10365
+ // into. That local terminal is what accepted the write, which is why a copy
10366
+ // over SSH works and then warns that it did not. Warning there is a lie, so
10367
+ // the caveat stays quiet inside an SSH session.
10368
+ //
10369
+ // The test is the session itself and not `paintLink`, which is also `'ssh'`
10370
+ // on a local window whenever the launcher relayed the frame: there the
10371
+ // capability table *did* describe the terminal that receives the bytes, so
10372
+ // the caveat is exactly what the user needs to read (the terminal probe
10373
+ // asserts it does, for seven terminal profiles).
10374
+ if (!this.terminalCaps.osc52 && !this.sshSession && !this.osc52HintShown) {
9754
10375
  this.osc52HintShown = true;
9755
10376
  this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
9756
10377
  }
9757
10378
  this.markDirty();
9758
10379
  }
9759
10380
  copyFocusedCard(arg = '') {
9760
- if (this.dialog !== undefined)
10381
+ // A dialog covers the input line, so the copy key is the only way to copy
10382
+ // from inside one. The inspect overlay carries what it is showing; every
10383
+ // other dialog keeps the key inert, exactly as it always was.
10384
+ const overlay = this.dialog?.kind === 'inspect' ? this.dialog : undefined;
10385
+ if (this.dialog !== undefined && overlay === undefined)
9761
10386
  return false;
9762
10387
  // `error` copies the newest failure or diagnostic row: a long path or a
9763
10388
  // command in one of those must reach the clipboard whole, which is exactly
@@ -9775,14 +10400,30 @@ export class SshTui {
9775
10400
  return true;
9776
10401
  }
9777
10402
  if (arg.trim() === '') {
9778
- const picked = copyTextFromTranscript(this.rows, this.focusedRow);
10403
+ const shown = overlay?.copyText;
10404
+ const picked = shown !== undefined && shown.trim() !== ''
10405
+ ? { text: shown, source: 'focused' }
10406
+ : copyTextFromTranscript(this.rows, this.focusedRow);
9779
10407
  if (picked.text.trim() === '') {
9780
10408
  this.pushRow({ kind: 'system', text: t('copy.empty') });
9781
10409
  this.markDirty();
9782
10410
  return false;
9783
10411
  }
9784
- const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
9785
- this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
10412
+ // The label follows the text: when the overlay supplied it, the row the
10413
+ // cursor sits on may be a different card entirely (Ctrl+N/P used to move
10414
+ // it behind the overlay), and naming that row would be a false report.
10415
+ const source = shown !== undefined && shown.trim() !== ''
10416
+ ? t('copy.sourceFull')
10417
+ : picked.source === 'focused'
10418
+ ? t(this.focusedRow?.kind === 'assistant' ? 'copy.sourceFocusedReply' : 'copy.sourceFocused')
10419
+ : t('copy.sourceAssistant');
10420
+ const notice = t('copy.ok', { chars: picked.text.length, source });
10421
+ this.copyPlainText(picked.text, notice);
10422
+ if (overlay !== undefined) {
10423
+ // The notice row is behind the overlay, so the overlay says it too.
10424
+ overlay.notice = notice;
10425
+ this.markDirty();
10426
+ }
9786
10427
  return true;
9787
10428
  }
9788
10429
  this.pushRow({ kind: 'system', text: t('copy.usage') });
@@ -9832,6 +10473,14 @@ export class SshTui {
9832
10473
  return;
9833
10474
  }
9834
10475
  this.scrollOffset = 0;
10476
+ // A selected reply has no card to fold, so Enter has to reach it too: the
10477
+ // gate below only asks about collapsible rows, and in a session with no card
10478
+ // at all (a plain Q&A, or right after `/clear`) that left Enter doing
10479
+ // nothing while `Alt+4` / Ctrl+N had just selected the reply.
10480
+ if (this.input.trim() === '' && this.focusedRow !== null && this.focusedRow.kind === 'assistant') {
10481
+ this.toggleCollapsible();
10482
+ return;
10483
+ }
9835
10484
  if (this.input.trim() === '' && this.collapsibleRows().length > 0) {
9836
10485
  this.toggleCollapsible();
9837
10486
  return;
@@ -9861,6 +10510,7 @@ export class SshTui {
9861
10510
  this.history.push(text);
9862
10511
  this.historyIndex = this.history.length;
9863
10512
  this.historyDraft = '';
10513
+ this.lastUserText = text;
9864
10514
  this.input = '';
9865
10515
  this.cursor = 0;
9866
10516
  this.inputFolded = false;
@@ -10006,6 +10656,12 @@ export class SshTui {
10006
10656
  this.markDirty();
10007
10657
  });
10008
10658
  break;
10659
+ case 'notify':
10660
+ void this.runNotifyCommand(arg).catch((error) => {
10661
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'notify', error: errorChain(error) }) });
10662
+ this.markDirty();
10663
+ });
10664
+ break;
10009
10665
  case 'disconnect':
10010
10666
  void this.runDisconnectCommand(arg).catch((error) => {
10011
10667
  if (error instanceof UserQuestionError) {
@@ -10017,6 +10673,12 @@ export class SshTui {
10017
10673
  this.markDirty();
10018
10674
  });
10019
10675
  break;
10676
+ case 'retryauth':
10677
+ void this.runRetryAuthCommand(arg).catch((error) => {
10678
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'retryauth', error: errorChain(error) }) });
10679
+ this.markDirty();
10680
+ });
10681
+ break;
10020
10682
  case 'copy':
10021
10683
  this.copyFocusedCard(arg);
10022
10684
  break;