dsh-ssh-tui 0.7.3 → 0.7.4

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 +98 -27
  2. package/README.md +77 -27
  3. package/docs/remote-ops.md +27 -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/commands.js +1 -0
  9. package/lib/commands.js.map +1 -1
  10. package/lib/copy-text.js +2 -0
  11. package/lib/copy-text.js.map +1 -1
  12. package/lib/diag.js +3 -0
  13. package/lib/diag.js.map +1 -1
  14. package/lib/dialogs.js.map +1 -1
  15. package/lib/display-sock.js +313 -2
  16. package/lib/display-sock.js.map +1 -1
  17. package/lib/dsh-compat.js +46 -0
  18. package/lib/dsh-compat.js.map +1 -1
  19. package/lib/i18n/en.js +40 -14
  20. package/lib/i18n/en.js.map +1 -1
  21. package/lib/i18n/index.js +5 -0
  22. package/lib/i18n/index.js.map +1 -1
  23. package/lib/i18n/zh.js +40 -14
  24. package/lib/i18n/zh.js.map +1 -1
  25. package/lib/index.js +75 -15
  26. package/lib/index.js.map +1 -1
  27. package/lib/line-mode.js +4 -0
  28. package/lib/line-mode.js.map +1 -1
  29. package/lib/paint.js +32 -1
  30. package/lib/paint.js.map +1 -1
  31. package/lib/picker.js +104 -15
  32. package/lib/picker.js.map +1 -1
  33. package/lib/plan.js +8 -0
  34. package/lib/plan.js.map +1 -1
  35. package/lib/platform.js +81 -0
  36. package/lib/platform.js.map +1 -1
  37. package/lib/preset-rows.js +54 -24
  38. package/lib/preset-rows.js.map +1 -1
  39. package/lib/question-wait.js +418 -0
  40. package/lib/question-wait.js.map +1 -0
  41. package/lib/selection.js +26 -8
  42. package/lib/selection.js.map +1 -1
  43. package/lib/term-text.js +79 -8
  44. package/lib/term-text.js.map +1 -1
  45. package/lib/terminal-input.js +13 -0
  46. package/lib/terminal-input.js.map +1 -1
  47. package/lib/tui.js +597 -51
  48. package/lib/tui.js.map +1 -1
  49. package/lib/types/attach.d.ts +8 -0
  50. package/lib/types/commands.d.ts +3 -0
  51. package/lib/types/diag.d.ts +2 -0
  52. package/lib/types/dialogs.d.ts +16 -0
  53. package/lib/types/display-sock.d.ts +78 -2
  54. package/lib/types/dsh-compat.d.ts +44 -12
  55. package/lib/types/i18n/index.d.ts +13 -3
  56. package/lib/types/paint.d.ts +21 -0
  57. package/lib/types/picker.d.ts +22 -0
  58. package/lib/types/platform.d.ts +22 -0
  59. package/lib/types/preset-rows.d.ts +18 -10
  60. package/lib/types/question-wait.d.ts +221 -0
  61. package/lib/types/selection.d.ts +12 -4
  62. package/lib/types/settings-subagent.d.ts +3 -3
  63. package/lib/types/subagent-model.d.ts +3 -3
  64. package/lib/types/term-text.d.ts +14 -0
  65. package/lib/types/terminal-input.d.ts +10 -0
  66. package/lib/types/transcript-types.d.ts +22 -0
  67. package/lib/types/tui.d.ts +126 -3
  68. package/lib/types/update-check.d.ts +19 -4
  69. package/lib/types/workspace-changes.d.ts +135 -0
  70. package/lib/update-check.js +25 -8
  71. package/lib/update-check.js.map +1 -1
  72. package/lib/workspace-changes.js +120 -0
  73. package/lib/workspace-changes.js.map +1 -0
  74. package/package.json +80 -42
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';
@@ -59,10 +59,12 @@ import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentMod
59
59
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
60
60
  import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
61
61
  import { displayToolName, subagentCourtesyName } from './job-label.js';
62
+ import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
63
+ import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
62
64
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
63
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';
64
66
  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';
67
+ 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
68
  import { TerminalInputGuard } from './terminal-input.js';
67
69
  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
70
  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 +447,25 @@ const DEEPSEEK_LOGO_VARIANTS = [
445
447
  ],
446
448
  },
447
449
  ];
450
+ /**
451
+ * What marks the row the card cursor is on.
452
+ *
453
+ * The card headers spell `▶ ` themselves; a reply is drawn by the markdown
454
+ * renderer, so its marker is prepended and its width is measured from this
455
+ * string (three cells, or two once the ASCII fallback has rewritten it).
456
+ */
457
+ const REPLY_FOCUS_MARKER = '▶ ';
458
+ /**
459
+ * The text an inspect overlay hands to the copy key: what the overlay shows.
460
+ *
461
+ * The overlays are the one place the copy key can be pressed without leaving
462
+ * them (a dialog covers the input line), so the body on screen is what "copy"
463
+ * means there — not the card row that opened it, which for a changes card is a
464
+ * file list and for a tool card a summary the reader has already scrolled past.
465
+ */
466
+ function inspectCopyText(lines) {
467
+ return lines.map(line => line.text).join('\n').replace(/\s+$/u, '');
468
+ }
448
469
  export function parseDisconnectPolicy(raw) {
449
470
  const id = raw.trim().toLowerCase();
450
471
  if (id === 'pause' || id === 'cancel' || id === '暂停')
@@ -626,6 +647,7 @@ export class SshTui {
626
647
  relayRows;
627
648
  onHangup;
628
649
  onReattach;
650
+ onDetach;
629
651
  renderTimer;
630
652
  decoder = new StringDecoder('utf8');
631
653
  color;
@@ -728,6 +750,15 @@ export class SshTui {
728
750
  inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
729
751
  thinkingStartedAt;
730
752
  waitStartedAt;
753
+ /**
754
+ * Whether the running turn produced anything a person can read: reply text or
755
+ * a tool card. A turn that ends with thinking only is the upstream returning
756
+ * an empty stop, and painting 完成 for it reads as a finished answer — see
757
+ * the hint at `turn/end`.
758
+ */
759
+ turnSawOutput = false;
760
+ /** Whether the turn produced thinking, which the empty-stop hint names. */
761
+ turnSawReasoning = false;
731
762
  completionSignaled = false;
732
763
  replaying = false;
733
764
  /** Display-line budget for the first paint after resume; 0 = full transcript. */
@@ -781,10 +812,22 @@ export class SshTui {
781
812
  detachedDeniedCount = 0;
782
813
  /** Questions waiting for a display right now — their cards do not exist yet. */
783
814
  queuedQuestions = 0;
815
+ /** When the current detached wait began, so the reconnect can say how long. */
816
+ questionWaitSince;
784
817
  paintIntervalMs;
785
818
  paintLink = 'local';
819
+ /**
820
+ * Whether this process runs inside an SSH session, from the sshd environment.
821
+ *
822
+ * Distinct from `paintLink`, which `applyProbedRtt` pins to `'ssh'` for any
823
+ * relayed frame — a local window included. Only this flag says the capability
824
+ * table described a remote tty rather than the terminal the bytes reach.
825
+ */
826
+ sshSession = false;
786
827
  paintProbed = false;
787
828
  paintRttMs;
829
+ /** The last few measured round-trips; the chip and the budget use their median. */
830
+ paintRttHistory = [];
788
831
  sessionTitle = '';
789
832
  llmRetry;
790
833
  quotaSnapshot;
@@ -833,6 +876,7 @@ export class SshTui {
833
876
  this.onRouteSettled = config.onRouteSettled;
834
877
  this.onHangup = config.onHangup;
835
878
  this.onReattach = config.onReattach;
879
+ this.onDetach = config.onDetach;
836
880
  this.headlessDisplay = config.headlessDisplay === true;
837
881
  // Line mode: no frames at all, one appended line per event. The alternate
838
882
  // screen is what makes a screen reader and a `tee` lose events, so it stays
@@ -844,7 +888,13 @@ export class SshTui {
844
888
  this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
845
889
  this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
846
890
  this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
847
- this.paintLink = detectSshSession() ? 'ssh' : 'local';
891
+ // Whether this process is inside an SSH session, kept apart from
892
+ // `paintLink`: that one also becomes `'ssh'` whenever the launcher relayed
893
+ // the frame (see `applyProbedRtt`), which is true for a purely local window
894
+ // too. This field answers the narrower question the clipboard caveat needs —
895
+ // "is the terminal the capability table described the one the bytes reach".
896
+ this.sshSession = detectSshSession();
897
+ this.paintLink = this.sshSession ? 'ssh' : 'local';
848
898
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
849
899
  ssh: this.paintLink === 'ssh',
850
900
  });
@@ -1013,7 +1063,7 @@ export class SshTui {
1013
1063
  return;
1014
1064
  this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
1015
1065
  this.markDirty();
1016
- const result = await installPluginLatest(info.profile);
1066
+ const result = await installPluginLatest(info.profile, info.latest);
1017
1067
  if (this.disposed)
1018
1068
  return;
1019
1069
  if (result.ok) {
@@ -1687,7 +1737,7 @@ export class SshTui {
1687
1737
  /** `/fix <row>`: repair one named roster row of the profile patch. */
1688
1738
  async runFixCommand(arg) {
1689
1739
  const name = arg.trim().toLowerCase();
1690
- const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1740
+ const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || (candidate.name ?? '').toLowerCase() === name);
1691
1741
  if (row === undefined) {
1692
1742
  this.pushRow({
1693
1743
  kind: 'error',
@@ -1795,6 +1845,11 @@ export class SshTui {
1795
1845
  return true;
1796
1846
  if (this.compactionRunning())
1797
1847
  return true;
1848
+ // A question queued for an answer that has not come. The turn itself has
1849
+ // settled, so nothing else here counts it, and the idle exit would otherwise
1850
+ // kill the Host while it is the only thing still holding that question.
1851
+ if (this.queuedQuestions > 0)
1852
+ return true;
1798
1853
  return false;
1799
1854
  }
1800
1855
  /**
@@ -1831,6 +1886,13 @@ export class SshTui {
1831
1886
  this.reattachedDuringHangup = false;
1832
1887
  ignoreFurtherHangupSignals();
1833
1888
  this.detachDisplay();
1889
+ // Say "no display" now rather than after the cancel/flush below. The lock's
1890
+ // `attached` is what a resume reads to decide that another window is on this
1891
+ // session, and for the whole cancel window it was still set while the window
1892
+ // was already gone — so a reconnect inside that window was refused as
1893
+ // "already attached". A reattach during this hangup patches it back (and the
1894
+ // writes are serialized launcher-side, so the last one wins).
1895
+ await this.onDetach?.();
1834
1896
  const busy = this.isBusyForHangupKeepalive();
1835
1897
  const pauseTurn = this.disconnectPolicy !== 'continue';
1836
1898
  if (pauseTurn && this.agent.status === 'running') {
@@ -1855,6 +1917,11 @@ export class SshTui {
1855
1917
  return;
1856
1918
  }
1857
1919
  const keepHost = this.displayHost !== undefined && busy;
1920
+ if (!keepHost) {
1921
+ // The Host is about to exit, and a marker left behind would keep saying a
1922
+ // question is waiting after the process that held it is gone.
1923
+ void clearWaitingMarker(String(this.agent.id));
1924
+ }
1858
1925
  if (keepHost) {
1859
1926
  this.hangingUp = false;
1860
1927
  // A relay that arrived while this hangup was unwinding set
@@ -2044,6 +2111,9 @@ export class SshTui {
2044
2111
  parts.push(t('attach.awayDetached', { count: detached }));
2045
2112
  if (waiting > 0)
2046
2113
  parts.push(t('attach.awayWaiting', { count: waiting }));
2114
+ if (waiting > 0 && this.questionWaitSince !== undefined) {
2115
+ parts.push(t('attach.questionWaited', { waited: formatShortDuration(Date.now() - this.questionWaitSince) }));
2116
+ }
2047
2117
  if (parts.length === 0)
2048
2118
  return;
2049
2119
  this.pushRow({
@@ -2107,18 +2177,38 @@ export class SshTui {
2107
2177
  currentDisconnectPolicy() {
2108
2178
  return this.disconnectPolicy;
2109
2179
  }
2180
+ /** The paint cadence in force right now: the link tier the footer shows. */
2181
+ currentPaintIntervalMs() {
2182
+ return this.paintIntervalMs;
2183
+ }
2110
2184
  applyProbedRtt(rttMs) {
2111
2185
  const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
2112
2186
  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;
2187
+ if (rttMs !== undefined) {
2188
+ // The relay re-measures the link on its own cadence, so this is called
2189
+ // again and again over one session. The reported round-trip is the median
2190
+ // of the last few measurements: a single burst that reads like a
2191
+ // 2-second link must not pin the footer chip — and with it the paint
2192
+ // cadence and the per-frame byte budget — while two measurements that
2193
+ // agree move it at once. That is the fix for a link that was red from the
2194
+ // first second to the last: the first measurement is taken while the
2195
+ // session is still booting, and it used to be the only one ever taken.
2196
+ this.paintRttHistory.push(rttMs);
2197
+ if (this.paintRttHistory.length > RTT_HISTORY)
2198
+ this.paintRttHistory.shift();
2199
+ this.paintProbed = true;
2200
+ this.paintRttMs = medianRtt(this.paintRttHistory);
2201
+ }
2202
+ else if (!this.paintProbed) {
2203
+ // A probe that missed its window reports "unknown". A link that was never
2204
+ // measured (a pipe, a terminal that does not answer DSR) stays unknown and
2205
+ // keeps the unprobed wording; one that has a measurement keeps its number
2206
+ // rather than blanking the chip to four hollow circles after every
2207
+ // auto-reconnect.
2208
+ this.paintRttMs = undefined;
2209
+ }
2120
2210
  if (!(Number.isFinite(envOverride) && envOverride > 0)) {
2121
- this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
2211
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: this.paintRttMs });
2122
2212
  if (!this.lineMode)
2123
2213
  this.startRenderTimer();
2124
2214
  }
@@ -2195,7 +2285,8 @@ export class SshTui {
2195
2285
  || row.kind === 'plan'
2196
2286
  || row.kind === 'question'
2197
2287
  || row.kind === 'goal'
2198
- || row.kind === 'compaction');
2288
+ || row.kind === 'compaction'
2289
+ || row.kind === 'changes');
2199
2290
  for (const burst of compactToolBursts(this.rows)) {
2200
2291
  const callAnchor = burst.groups.calls.at(-1);
2201
2292
  const editAnchor = burst.groups.edits.at(-1);
@@ -2213,13 +2304,35 @@ export class SshTui {
2213
2304
  || row.kind === 'question'
2214
2305
  || row.kind === 'goal'
2215
2306
  || row.kind === 'compaction'
2216
- || row.kind === 'prompt');
2307
+ || row.kind === 'prompt'
2308
+ || row.kind === 'changes');
2217
2309
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
2218
2310
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
2219
2311
  rows.push(this.streamingReasoning);
2220
2312
  }
2221
2313
  return rows;
2222
2314
  }
2315
+ /**
2316
+ * Everything the card cursor walks, in transcript order: the collapsible
2317
+ * cards plus the model replies.
2318
+ *
2319
+ * Built from the two lists rather than from the rows alone, because one of the
2320
+ * collapsible entries is synthetic: the live thinking block exists only while a
2321
+ * turn streams and is never a member of `this.rows`. Filtering the rows (the
2322
+ * first version of this) silently dropped it out of the cursor walk — ↑ on a
2323
+ * turn that had only streamed thinking did nothing, and Enter could no longer
2324
+ * expand the card being written. It belongs at the end, which is where it is
2325
+ * painted, so anything not in the rows is appended.
2326
+ */
2327
+ focusRing() {
2328
+ const collapsible = this.collapsibleRows();
2329
+ const ring = this.rows.filter(row => row.kind === 'assistant' || collapsible.includes(row));
2330
+ const known = new Set(this.rows);
2331
+ for (const row of collapsible)
2332
+ if (!known.has(row))
2333
+ ring.push(row);
2334
+ return ring;
2335
+ }
2223
2336
  spinnerFrame(periodMs = 120) {
2224
2337
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
2225
2338
  }
@@ -2357,7 +2470,7 @@ export class SshTui {
2357
2470
  // instruction that used to paint as `❯ …`.
2358
2471
  const message = createUserMessage({
2359
2472
  content: [{ type: 'text', text }],
2360
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary: queued },
2473
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary: queued },
2361
2474
  });
2362
2475
  try {
2363
2476
  this.agent.followup(message);
@@ -2568,7 +2681,10 @@ export class SshTui {
2568
2681
  if (dialog === undefined || dialog.kind !== 'inspect')
2569
2682
  return;
2570
2683
  const header = this.styleLine('system', truncateToWidth(dialog.title, width));
2571
- const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
2684
+ // A copy from inside the overlay replaces the key legend for the rest of the
2685
+ // visit: the notice row it also pushes is behind this screen, and a copy the
2686
+ // reader cannot confirm is the one thing worse than no copy at all.
2687
+ const hint = this.styleLine('system', truncateToWidth(dialog.notice ?? t('tool.inspectHint'), width));
2572
2688
  const divider = this.styleLine('system', repeatToWidth('─', width));
2573
2689
  const bodyBudget = Math.max(1, height - 4);
2574
2690
  const rendered = [];
@@ -2645,6 +2761,7 @@ export class SshTui {
2645
2761
  title,
2646
2762
  lines,
2647
2763
  offset: 0,
2764
+ copyText: inspectCopyText(lines),
2648
2765
  });
2649
2766
  }
2650
2767
  openSubagentInspect(row) {
@@ -2652,6 +2769,8 @@ export class SshTui {
2652
2769
  if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
2653
2770
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
2654
2771
  dialog.lines = subagentInspectLines(row);
2772
+ dialog.copyText = inspectCopyText(dialog.lines);
2773
+ dialog.notice = undefined;
2655
2774
  this.markDirty();
2656
2775
  return;
2657
2776
  }
@@ -2665,8 +2784,36 @@ export class SshTui {
2665
2784
  lines,
2666
2785
  offset: 0,
2667
2786
  subagentSessionId: this.subagentInspectId(row),
2787
+ copyText: inspectCopyText(lines),
2668
2788
  });
2669
2789
  }
2790
+ /**
2791
+ * Read one reply full-screen.
2792
+ *
2793
+ * What Enter does on a selected reply: a reply has no body to fold away, and
2794
+ * the transcript wraps it to the window — which for a long answer with tables
2795
+ * or code blocks is not how it reads. The overlay is the same surface the tool
2796
+ * cards use, and the copy key works inside it, so "read it, then copy it"
2797
+ * needs no trip back through the timeline.
2798
+ */
2799
+ openReplyInspect(row) {
2800
+ // Line mode has no scroller, and `echoInspectToLog` would append the reply
2801
+ // to the log a second time — it is already there, in full. Nothing to open.
2802
+ if (this.lineMode)
2803
+ return;
2804
+ // `- 2`, matching `paintInspectOverlay`, which re-wraps every body line at
2805
+ // `width - 2` inside a two-space indent: rendered wider, long table and code
2806
+ // rows were re-wrapped mid-cell at narrow widths.
2807
+ const width = Math.max(20, this.screenColumns() - 2);
2808
+ // Plain: `styleLine` strips escapes, so a coloured render would lose its
2809
+ // styling anyway — and the overlay's own frame supplies the base style.
2810
+ const lines = renderMarkdownLines(row.text, width, false, false)
2811
+ .map(line => ({ kind: 'assistant', text: line }));
2812
+ const title = t('reply.inspectTitle', { lines: lines.length });
2813
+ if (this.echoInspectToLog(title, lines))
2814
+ return;
2815
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: row.text });
2816
+ }
2670
2817
  /**
2671
2818
  * Line mode has no framed scroller, so an inspect body goes into the log.
2672
2819
  *
@@ -2711,9 +2858,27 @@ export class SshTui {
2711
2858
  addDisplay(this.selectLine(styled, focused), row);
2712
2859
  }
2713
2860
  }
2714
- /** Move the expand/collapse focus among reasoning and tool rows. */
2715
- moveCollapsibleFocus(delta) {
2716
- const rows = this.collapsibleRows();
2861
+ /**
2862
+ * Which file line of an expanded changes card the screen cursor is on.
2863
+ *
2864
+ * The card paints its header, then one row per file, and `selectableLines`
2865
+ * holds exactly the viewport. The cursor sits on the input row unless the
2866
+ * reader scrolled, so the file is how many painted rows above the cursor the
2867
+ * card's header is. A cursor on the header, the remainder line, or the prompt
2868
+ * falls back to the first file, which is the only defensible guess.
2869
+ */
2870
+ changesFileIndex(row) {
2871
+ const painted = this.selectableLines.findIndex(line => line.ref === row);
2872
+ if (painted < 0)
2873
+ return 0;
2874
+ const index = this.lastPaintCursorRow - 1 - (this.lastTranscriptStart + painted);
2875
+ if (index < 1 || index > row.files.length)
2876
+ return 0;
2877
+ return index - 1;
2878
+ }
2879
+ /** Move the card cursor among the cards and the replies. */
2880
+ moveFocus(delta) {
2881
+ const rows = this.focusRing();
2717
2882
  if (rows.length === 0)
2718
2883
  return;
2719
2884
  if (this.focusedRow === null) {
@@ -2731,13 +2896,23 @@ export class SshTui {
2731
2896
  }
2732
2897
  /** Toggle the focused block; without focus, toggle the most recent one. */
2733
2898
  toggleCollapsible() {
2899
+ const focused = this.focusedRow;
2900
+ // A reply has no body to fold away, so Enter reads it full-screen instead.
2901
+ // Falling through to the lookup below would be worse than doing nothing:
2902
+ // a reply is not in `collapsibleRows()`, and the fallback is "the newest
2903
+ // card" — Enter on a selected reply would expand a card the reader never
2904
+ // picked.
2905
+ if (focused !== null && focused.kind === 'assistant') {
2906
+ this.openReplyInspect(focused);
2907
+ return;
2908
+ }
2734
2909
  const rows = this.collapsibleRows();
2735
2910
  if (rows.length === 0)
2736
2911
  return;
2737
- const focused = this.focusedRow !== null && rows.includes(this.focusedRow)
2738
- ? this.focusedRow
2912
+ const inRing = focused !== null && rows.includes(focused)
2913
+ ? focused
2739
2914
  : undefined;
2740
- const target = focused ?? rows[rows.length - 1];
2915
+ const target = inRing ?? rows[rows.length - 1];
2741
2916
  if (target === undefined)
2742
2917
  return;
2743
2918
  this.toggleCard(target);
@@ -2758,6 +2933,14 @@ export class SshTui {
2758
2933
  return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
2759
2934
  }
2760
2935
  toggleCard(target) {
2936
+ // A changes card opens its file list first; the next Enter reads the file
2937
+ // the cursor is on. With one file there is nothing to choose, so that Enter
2938
+ // opens it directly.
2939
+ if (target.kind === 'changes' && target.expanded && target.files.length > 0) {
2940
+ this.focusedRow = target;
2941
+ this.openChangesInspect(target, target.files.length === 1 ? 0 : this.changesFileIndex(target));
2942
+ return;
2943
+ }
2761
2944
  if (target.kind === 'subagent') {
2762
2945
  this.focusedRow = target;
2763
2946
  this.openSubagentInspect(target);
@@ -2782,11 +2965,14 @@ export class SshTui {
2782
2965
  const rows = this.collapsibleRows();
2783
2966
  if (rows.length === 0)
2784
2967
  return;
2968
+ // Ctrl+R acts on the cards; a selected reply is not one, and it must survive
2969
+ // the sweep either way (it was not collapsed, so it must not be deselected).
2970
+ const selected = this.focusedRow?.kind === 'assistant' ? this.focusedRow : undefined;
2785
2971
  const allExpanded = rows.every(row => row.expanded);
2786
2972
  if (allExpanded) {
2787
2973
  for (const row of rows)
2788
2974
  row.expanded = false;
2789
- this.focusedRow = null;
2975
+ this.focusedRow = selected ?? null;
2790
2976
  }
2791
2977
  else {
2792
2978
  const width = Math.max(10, this.screenColumns());
@@ -2802,7 +2988,7 @@ export class SshTui {
2802
2988
  }
2803
2989
  row.expanded = true;
2804
2990
  }
2805
- this.focusedRow = rows[rows.length - 1] ?? null;
2991
+ this.focusedRow = selected ?? rows[rows.length - 1] ?? null;
2806
2992
  }
2807
2993
  this.forceFullPaint = true;
2808
2994
  this.markDirty();
@@ -2866,6 +3052,14 @@ export class SshTui {
2866
3052
  this.focusedRow = row;
2867
3053
  this.forceFullPaint = true;
2868
3054
  }
3055
+ else if (row.kind === 'assistant') {
3056
+ // A reply has nothing to expand, but revealing it must leave it selected:
3057
+ // Alt+4 and `/find 回复` land here, and clearing the focus (which is what
3058
+ // this branch used to do) meant `/copy` afterwards only worked by falling
3059
+ // back to "the latest reply" — silently, and only when it happened to be
3060
+ // the same row.
3061
+ this.focusedRow = row;
3062
+ }
2869
3063
  else {
2870
3064
  this.focusedRow = null;
2871
3065
  }
@@ -2964,14 +3158,39 @@ export class SshTui {
2964
3158
  }
2965
3159
  const display = [];
2966
3160
  const displayRefs = [];
3161
+ // Cells at the start of each painted line that are chrome rather than text.
3162
+ // Only the focus marker has any, and the drag-selection needs to know how
3163
+ // many so a copy off the selected row does not paste the marker.
3164
+ const displayGutters = [];
2967
3165
  const searchHit = this.searchHits[this.searchIndex];
2968
- const addDisplay = (line, ref) => {
3166
+ const addDisplay = (line, ref, gutter = 0) => {
2969
3167
  display.push(clipAnsiToWidth(line, width));
2970
3168
  displayRefs.push(ref);
3169
+ displayGutters.push(gutter);
2971
3170
  };
2972
3171
  const pushRow = (kind, text, ref) => {
2973
3172
  if (kind === 'assistant') {
2974
- for (const line of renderMarkdownLines(text, width, this.color, this.terminalCaps.osc8)) {
3173
+ const focused = ref !== undefined && this.focusedRow === ref;
3174
+ // The same string the card headers use, so the ASCII fallback rewrites
3175
+ // both alike, and the width comes from it rather than from a constant
3176
+ // (in ASCII `▶` becomes `> `, and the marker is still three cells).
3177
+ const marker = sanitizeTerminalText(REPLY_FOCUS_MARKER);
3178
+ const gutter = displayWidth(marker);
3179
+ // A focused reply is rendered three cells narrower so the marker cannot
3180
+ // eat its last characters: painted at full width and then prefixed, the
3181
+ // tail of every one of its lines was clipped away silently (the card
3182
+ // headers avoid this by truncating to the same kind of margin).
3183
+ const inner = focused ? Math.max(1, width - gutter) : width;
3184
+ let placed = false;
3185
+ for (const line of renderMarkdownLines(text, inner, this.color, this.terminalCaps.osc8)) {
3186
+ // The marker goes on the first line that has something on it: a reply
3187
+ // starting with a blank line would otherwise carry a marker all by
3188
+ // itself, with the reader's selection nowhere near its text.
3189
+ if (focused && !placed && stripAnsi(line).trim() !== '') {
3190
+ addDisplay(`${marker}${line}`, ref, gutter);
3191
+ placed = true;
3192
+ continue;
3193
+ }
2975
3194
  addDisplay(line, ref);
2976
3195
  }
2977
3196
  return;
@@ -3188,6 +3407,18 @@ export class SshTui {
3188
3407
  }
3189
3408
  continue;
3190
3409
  }
3410
+ if (row.kind === 'changes') {
3411
+ const header = `${row.header}${row.expanded ? '' : t('card.expand')}`;
3412
+ this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width);
3413
+ if (row.expanded) {
3414
+ for (const file of row.files) {
3415
+ addDisplay(this.styleLine('tool-result', ` ${file}`), row);
3416
+ }
3417
+ if (row.more !== undefined)
3418
+ addDisplay(this.styleLine('system', ` ${row.more}`), row);
3419
+ }
3420
+ continue;
3421
+ }
3191
3422
  if (row.kind === 'compaction') {
3192
3423
  const running = row.status === 'running';
3193
3424
  const spinner = running ? ` ${this.spinnerFrame()}` : '';
@@ -3575,6 +3806,10 @@ export class SshTui {
3575
3806
  const start = window.start;
3576
3807
  const visible = window.visibleLines;
3577
3808
  const visibleRefs = window.visibleRefs;
3809
+ // `padding` blank rows are unshifted when the transcript is shorter than the
3810
+ // window, so the gutter of a visible line is read off the same offset the
3811
+ // window itself used (negative before the transcript starts → 0).
3812
+ const visibleGutters = visible.map((_, index) => displayGutters[start + index - window.padding] ?? 0);
3578
3813
  this.clickableRows.clear();
3579
3814
  this.paintedLinkHitsByRow.clear();
3580
3815
  this.transcriptTopScreenY = headerLines.length + 1;
@@ -3586,6 +3821,7 @@ export class SshTui {
3586
3821
  // Only a model reply is freely copyable; a tool card, a notice or the
3587
3822
  // chrome keeps its click behavior instead.
3588
3823
  copyable: visibleRefs[index]?.kind === 'assistant',
3824
+ gutter: visibleGutters[index] ?? 0,
3589
3825
  }));
3590
3826
  for (let index = 0; index < visibleRefs.length; index++) {
3591
3827
  const ref = visibleRefs[index];
@@ -4533,6 +4769,13 @@ export class SshTui {
4533
4769
  this.lastActivity = Date.now();
4534
4770
  if (!this.replaying)
4535
4771
  this.refreshContextPressure();
4772
+ // Before the switch, and by string: `workspace/changes` is not a key of the
4773
+ // 0.1.5 `SessionEventMap`, so a `case` for it fails strict compilation on
4774
+ // that tree. A Host without the service records nothing and this returns.
4775
+ if (String(event.type) === 'workspace/changes') {
4776
+ this.noteWorkspaceChanges(session.id, event.seq);
4777
+ return;
4778
+ }
4536
4779
  switch (event.type) {
4537
4780
  case 'user/message': {
4538
4781
  const text = event.data.content
@@ -4547,7 +4790,7 @@ export class SshTui {
4547
4790
  if (!this.replaying)
4548
4791
  this.beginWait();
4549
4792
  }
4550
- else if (sourceKind === 'plugin' && source.form === 'notice') {
4793
+ else if (source.form === 'notice') {
4551
4794
  const summary = source.summary?.trim() ?? '';
4552
4795
  // Body stays off the workspace (the model still received it).
4553
4796
  const last = this.rows.at(-1);
@@ -4564,12 +4807,17 @@ export class SshTui {
4564
4807
  if (!this.replaying)
4565
4808
  this.beginWait();
4566
4809
  }
4810
+ else if (source.form === 'snapshot') {
4811
+ // `form` is the ContextFormed discriminator every producer shares,
4812
+ // while `kind` is now per-producer (0.1.7 has no catch-all), so the
4813
+ // two content branches above key on the form: keying them on the
4814
+ // released `plugin` kind dropped a `time-context` snapshot — and
4815
+ // this plugin's own notices — into the raw-context row below.
4816
+ this.pushRow({ kind: 'system', text: text });
4817
+ }
4567
4818
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4568
4819
  this.pushPromptInjection(text, source.plugin);
4569
4820
  }
4570
- else if (sourceKind === 'plugin' && source.form === 'snapshot') {
4571
- this.pushRow({ kind: 'system', text: text });
4572
- }
4573
4821
  else {
4574
4822
  this.pushRow({ kind: 'system', text: t('prompt.contextPrefix', { text }) });
4575
4823
  }
@@ -4610,7 +4858,10 @@ export class SshTui {
4610
4858
  if (reasoning !== '') {
4611
4859
  this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
4612
4860
  }
4861
+ if (reasoning !== '')
4862
+ this.turnSawReasoning = true;
4613
4863
  if (text !== '') {
4864
+ this.turnSawOutput = true;
4614
4865
  this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
4615
4866
  }
4616
4867
  else if (interrupted && reasoning === '') {
@@ -4620,6 +4871,7 @@ export class SshTui {
4620
4871
  break;
4621
4872
  }
4622
4873
  case 'tool/call': {
4874
+ this.turnSawOutput = true;
4623
4875
  this.openToolCalls.set(String(event.data.callId), event.data.name);
4624
4876
  this.toolCallNames.set(String(event.data.callId), event.data.name);
4625
4877
  this.statsTracker.noteToolStart(String(event.data.callId), event.time);
@@ -4803,6 +5055,8 @@ export class SshTui {
4803
5055
  break;
4804
5056
  case 'turn/start':
4805
5057
  this.stalledWarningShown = false;
5058
+ this.turnSawOutput = false;
5059
+ this.turnSawReasoning = false;
4806
5060
  this.llmRetry = undefined;
4807
5061
  this.status = `turn ${event.data.turn} running`;
4808
5062
  // A turn is about to spend on this route: make sure the session's record
@@ -4841,6 +5095,17 @@ export class SshTui {
4841
5095
  if (reason.kind === 'error') {
4842
5096
  this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
4843
5097
  }
5098
+ if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
5099
+ // The provider ended the turn without an answer. Some gateways map a
5100
+ // Gemini/Claude reply that only contains a thought part onto
5101
+ // `reasoning_content` and then finish with stop and no content, so the
5102
+ // turn looks completed while nothing was said and the user is left to
5103
+ // guess that another Enter is what continues it. Say so instead.
5104
+ this.pushRow({
5105
+ kind: 'system',
5106
+ text: t(this.turnSawReasoning ? 'turn.emptyThinkingOnly' : 'turn.emptyReply'),
5107
+ });
5108
+ }
4844
5109
  const livePlan = this.findLivePlanRow();
4845
5110
  if (livePlan !== undefined && reason.kind === 'completed') {
4846
5111
  applyTurnEndToPlan(livePlan);
@@ -4926,6 +5191,100 @@ export class SshTui {
4926
5191
  this.status = 'disposed';
4927
5192
  this.markDirty();
4928
5193
  };
5194
+ /**
5195
+ * One `workspace/changes` event: the files a turn changed.
5196
+ *
5197
+ * The event carries only the turn number; the summary stays on the Host and
5198
+ * is served by `workspaceChanges` for this event's own sequence, and only
5199
+ * while the Session lives. A missing service (every 0.1.5 Host) or a summary
5200
+ * that can no longer be opened (a log replayed after a restart) shows
5201
+ * nothing — upstream's rule, so a card that cannot be read is never drawn.
5202
+ * A later event for the same turn replaces the earlier summary in place.
5203
+ */
5204
+ noteWorkspaceChanges(sessionId, seq) {
5205
+ if (typeof seq !== 'number')
5206
+ return;
5207
+ const service = workspaceChangesOf(this.ctx);
5208
+ if (service === undefined)
5209
+ return;
5210
+ const summary = service.summary(String(sessionId), seq);
5211
+ if (summary === undefined || !changesSummaryVisible(summary))
5212
+ return;
5213
+ const existing = this.rows.findLast((row) => row.kind === 'changes' && row.turn === summary.turn && row.sessionId === String(sessionId));
5214
+ if (existing !== undefined) {
5215
+ this.fillChangesCard(existing, summary, seq);
5216
+ }
5217
+ else {
5218
+ const row = {
5219
+ kind: 'changes',
5220
+ turn: summary.turn,
5221
+ seq,
5222
+ sessionId: String(sessionId),
5223
+ header: '',
5224
+ files: [],
5225
+ expanded: false,
5226
+ };
5227
+ this.fillChangesCard(row, summary, seq);
5228
+ this.pushRow(row);
5229
+ }
5230
+ this.markDirty();
5231
+ }
5232
+ /** Copy one summary onto its card. Shared by the first event and its replacements. */
5233
+ fillChangesCard(row, summary, seq) {
5234
+ row.seq = seq;
5235
+ row.header = changesHeader(summary);
5236
+ row.files = summary.files.map(changesFileLine);
5237
+ const more = changesRemainderLine(summary);
5238
+ if (more === undefined)
5239
+ delete row.more;
5240
+ else
5241
+ row.more = more;
5242
+ }
5243
+ /**
5244
+ * Full view of one file on a changes card: its comparison, as the service
5245
+ * computed it. Binary and oversized files have no lines, so the overlay
5246
+ * carries the one-line explanation instead of an empty body.
5247
+ */
5248
+ openChangesInspect(row, index) {
5249
+ const service = workspaceChangesOf(this.ctx);
5250
+ const label = row.files[index] ?? row.header;
5251
+ const title = t('changes.inspectTitle', { file: label });
5252
+ if (service === undefined) {
5253
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5254
+ return;
5255
+ }
5256
+ const controller = new AbortController();
5257
+ void service.diff(row.sessionId, row.seq, index, controller.signal)
5258
+ .then(diff => {
5259
+ const lines = renderChangesDiff(diff);
5260
+ this.openChangesInspectLines(title, lines.length > 0
5261
+ ? lines
5262
+ : [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5263
+ })
5264
+ .catch(() => {
5265
+ this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
5266
+ });
5267
+ }
5268
+ /** Open (or replace) the changes overlay, or echo it to the log in line mode. */
5269
+ openChangesInspectLines(title, lines) {
5270
+ if (this.echoInspectToLog(title, lines))
5271
+ return;
5272
+ const dialog = this.dialog;
5273
+ if (dialog?.kind === 'inspect') {
5274
+ dialog.title = title;
5275
+ dialog.lines = lines;
5276
+ dialog.offset = 0;
5277
+ // The dialog may be a leftover from another overlay, and this path is
5278
+ // asynchronous: a reply overlay opened while the diff was being read would
5279
+ // keep its `copyText`, so the copy key would hand back the reply under a
5280
+ // diff. Everything the previous body owned is replaced here.
5281
+ dialog.copyText = inspectCopyText(lines);
5282
+ dialog.notice = undefined;
5283
+ this.markDirty();
5284
+ return;
5285
+ }
5286
+ this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: inspectCopyText(lines) });
5287
+ }
4929
5288
  /** Plan-mode / command / team events that plugins merge into SessionEventMap. */
4930
5289
  handleExtensionEvent(event) {
4931
5290
  const type = String(event.type);
@@ -5647,6 +6006,10 @@ export class SshTui {
5647
6006
  return;
5648
6007
  dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
5649
6008
  dialog.lines = subagentInspectLines(row);
6009
+ // The body grows while the child runs, and the copy text is that body: it
6010
+ // has to move with it or the key hands back the log as it stood when the
6011
+ // overlay opened.
6012
+ dialog.copyText = inspectCopyText(dialog.lines);
5650
6013
  }
5651
6014
  // ── approval and questions ──────────────────────────────────────────────
5652
6015
  hasLiveDisplay() {
@@ -5656,6 +6019,64 @@ export class SshTui {
5656
6019
  return this.displayHost?.attached === true;
5657
6020
  return true;
5658
6021
  }
6022
+ /**
6023
+ * Tell the absent user that a question is waiting.
6024
+ *
6025
+ * The marker file is the part that needs no configuration: it sits next to the
6026
+ * session's stderr log, so logging back into the jump host shows it before the
6027
+ * TUI is open. The command is whatever the user pointed `ssh-tui.notify` at,
6028
+ * run once; it never blocks the wait and never reports its own failure.
6029
+ */
6030
+ announceWaitingQuestion(request) {
6031
+ const sessionId = String(this.agent.id);
6032
+ const question = request.questions
6033
+ .map(item => item.question.trim())
6034
+ .filter(text => text !== '')
6035
+ .join(' / ')
6036
+ .slice(0, 200);
6037
+ const since = this.questionWaitSince ?? Date.now();
6038
+ void writeWaitingMarker({
6039
+ sessionId,
6040
+ count: this.queuedQuestions,
6041
+ question,
6042
+ since,
6043
+ });
6044
+ const command = this.readNotifyCommand();
6045
+ if (command === undefined)
6046
+ return;
6047
+ const smtp = this.readNotifySmtp();
6048
+ const context = {
6049
+ sessionId,
6050
+ count: this.queuedQuestions,
6051
+ question,
6052
+ waitedMs: Date.now() - since,
6053
+ resumeCommand: `dsh --profile ${profileFromArgv()} --resume ${sessionId}`,
6054
+ ...(smtp.user === undefined ? {} : { smtpUser: smtp.user }),
6055
+ ...(smtp.password === undefined ? {} : { smtpPassword: smtp.password }),
6056
+ };
6057
+ void runNotify(command, context);
6058
+ }
6059
+ /** The SMTP account `/notify smtp` saved, when the target is authenticated. */
6060
+ readNotifySmtp() {
6061
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6062
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
6063
+ return {};
6064
+ const saved = raw;
6065
+ const user = typeof saved.notifySmtpUser === 'string' ? saved.notifySmtpUser : undefined;
6066
+ const password = typeof saved.notifySmtpPassword === 'string' ? saved.notifySmtpPassword : undefined;
6067
+ return {
6068
+ ...(user === undefined || user === '' ? {} : { user }),
6069
+ ...(password === undefined || password === '' ? {} : { password }),
6070
+ };
6071
+ }
6072
+ /** `DSH_TUI_NOTIFY`, else the saved `ssh-tui.notify`. Empty means off. */
6073
+ readNotifyCommand() {
6074
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
6075
+ const saved = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
6076
+ ? raw.notify
6077
+ : undefined;
6078
+ return notifyCommand(process.env, typeof saved === 'string' ? saved : undefined);
6079
+ }
5659
6080
  waitForLiveDisplay(signal) {
5660
6081
  if (this.hasLiveDisplay())
5661
6082
  return Promise.resolve();
@@ -5792,7 +6213,7 @@ export class SshTui {
5792
6213
  ...(request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason }),
5793
6214
  ...(this.hostSandboxMode === undefined || this.hostSandboxMode.trim() === '' ? {} : { sandboxMode: this.hostSandboxMode }),
5794
6215
  }) }],
5795
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6216
+ source: { kind: TUI_SOURCE_KIND },
5796
6217
  })],
5797
6218
  system: reviewSystemPrompt(getLocale()),
5798
6219
  maxTokens: 400,
@@ -5874,6 +6295,10 @@ export class SshTui {
5874
6295
  * `the user rejected tool "bash"`. Steer a plugin notice with the real
5875
6296
  * classifier/reviewer reason. Summary matches the workspace decision row
5876
6297
  * so the handler does not paint the body twice.
6298
+ *
6299
+ * This notice is committed to the session, so it is exactly the injection a
6300
+ * V4 log refuses when it wears the released `plugin` wrapper — see
6301
+ * {@link TUI_SOURCE_KIND}.
5877
6302
  */
5878
6303
  tellModelApprovalDenied(command, reason, summary) {
5879
6304
  if (this.replaying || this.agentGone)
@@ -5881,7 +6306,7 @@ export class SshTui {
5881
6306
  const text = t('approval.modelDenied', { command, reason });
5882
6307
  const message = createUserMessage({
5883
6308
  content: [{ type: 'text', text }],
5884
- source: { kind: 'plugin', plugin: 'dsh-ssh-tui', form: 'notice', summary },
6309
+ source: { kind: TUI_SOURCE_KIND, form: 'notice', summary },
5885
6310
  });
5886
6311
  try {
5887
6312
  if (this.agent.status === 'running')
@@ -5991,11 +6416,20 @@ export class SshTui {
5991
6416
  // Counted here, not read off the rows: the card is built only after the
5992
6417
  // wait resolves, so at reattach time a queued question has no row yet.
5993
6418
  this.queuedQuestions += 1;
6419
+ this.questionWaitSince ??= Date.now();
6420
+ // The user learns about the wait from a marker file and, when they
6421
+ // configured one, a command. Both run after the counter moves, so a
6422
+ // reconnect during either still sees the question as queued.
6423
+ this.announceWaitingQuestion(request);
5994
6424
  try {
5995
6425
  await this.waitForLiveDisplay(request.signal);
5996
6426
  }
5997
6427
  finally {
5998
6428
  this.queuedQuestions -= 1;
6429
+ if (this.queuedQuestions === 0) {
6430
+ this.questionWaitSince = undefined;
6431
+ void clearWaitingMarker(String(this.agent.id));
6432
+ }
5999
6433
  }
6000
6434
  }
6001
6435
  const answers = [];
@@ -7561,6 +7995,44 @@ export class SshTui {
7561
7995
  this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
7562
7996
  this.markDirty();
7563
7997
  }
7998
+ /**
7999
+ * /notify: where a question waiting on an absent user is announced.
8000
+ *
8001
+ * No argument shows what is configured. `off` clears it. `mail`, `smtp` and
8002
+ * `local` store the command that target needs; the SMTP password is kept
8003
+ * beside it and handed to the command through the environment, so it is never
8004
+ * part of the command string.
8005
+ */
8006
+ async runNotifyCommand(arg) {
8007
+ const target = parseNotifyTarget(arg);
8008
+ if (target === undefined) {
8009
+ this.pushRow({ kind: 'error', text: t('notify.usage') });
8010
+ this.markDirty();
8011
+ return;
8012
+ }
8013
+ if (target.kind === 'off' && arg.trim() === '') {
8014
+ const current = this.readNotifyCommand();
8015
+ this.pushRow({
8016
+ kind: 'system',
8017
+ text: current === undefined ? t('notify.statusOff') : t('notify.statusOn', { command: current }),
8018
+ });
8019
+ this.markDirty();
8020
+ return;
8021
+ }
8022
+ if (target.kind === 'off') {
8023
+ await this.mergeUiSettings({ notify: '', notifySmtpUser: '', notifySmtpPassword: '' });
8024
+ this.pushRow({ kind: 'system', text: t('notify.cleared') });
8025
+ this.markDirty();
8026
+ return;
8027
+ }
8028
+ await this.mergeUiSettings({
8029
+ notify: notifyTargetCommand(target),
8030
+ notifySmtpUser: target.kind === 'smtp' ? target.user ?? '' : '',
8031
+ notifySmtpPassword: target.kind === 'smtp' ? target.password ?? '' : '',
8032
+ });
8033
+ this.pushRow({ kind: 'system', text: t('notify.saved', { target: notifyTargetLabel(target) }) });
8034
+ this.markDirty();
8035
+ }
7564
8036
  /** /disconnect: pause (default) or continue the turn after SSH drop. */
7565
8037
  async runDisconnectCommand(arg) {
7566
8038
  const direct = parseDisconnectPolicy(arg);
@@ -8104,13 +8576,21 @@ export class SshTui {
8104
8576
  // routing so a plain control byte reaches it as readily as a CSI sequence;
8105
8577
  // everything else keeps the handling below, which is what makes the defaults
8106
8578
  // unchanged by construction.
8107
- if (this.dialog === undefined && combined !== '') {
8108
- const moved = this.keymap.sequences.get(combined);
8579
+ const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
8580
+ // While a dialog is open its own routing owns the keys — with one exception:
8581
+ // an inspect overlay is the only place the copy key can be pressed at all,
8582
+ // and gating it put the reader's rebound key out of reach exactly where the
8583
+ // overlay's own hint advertises copying.
8584
+ const overlayCopy = this.dialog !== undefined
8585
+ && this.dialog.kind === 'inspect'
8586
+ && (this.dialog.copyText ?? '') !== ''
8587
+ && moved === 'copy';
8588
+ if ((this.dialog === undefined || overlayCopy) && combined !== '') {
8109
8589
  if (moved !== undefined && this.keymap.overridden.has(moved)) {
8110
8590
  if (this.runKeyAction(moved))
8111
8591
  return;
8112
8592
  }
8113
- if (this.keymap.suppressed.has(combined))
8593
+ if (this.dialog === undefined && this.keymap.suppressed.has(combined))
8114
8594
  return;
8115
8595
  }
8116
8596
  const escape = /^\x1b\[([A-D])$/u;
@@ -8135,7 +8615,7 @@ export class SshTui {
8135
8615
  this.markDirty();
8136
8616
  }
8137
8617
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8138
- this.moveCollapsibleFocus(-1);
8618
+ this.moveFocus(-1);
8139
8619
  }
8140
8620
  else {
8141
8621
  this.historyBack();
@@ -8159,7 +8639,7 @@ export class SshTui {
8159
8639
  this.markDirty();
8160
8640
  }
8161
8641
  else if (this.input === '' && this.collapsibleRows().length > 0) {
8162
- this.moveCollapsibleFocus(1);
8642
+ this.moveFocus(1);
8163
8643
  }
8164
8644
  else {
8165
8645
  this.historyForward();
@@ -8187,19 +8667,23 @@ export class SshTui {
8187
8667
  this.scrollInspectOrTranscript(-3);
8188
8668
  return;
8189
8669
  }
8190
- // 32 is "motion with the left button held": a drag over a reply.
8191
- if (button === 32) {
8670
+ // 32 is "motion with the left button held": a drag over a reply. Shift
8671
+ // adds 4 to every button code, and a terminal that still forwards the
8672
+ // report while Shift is down (instead of keeping it for its own
8673
+ // selection) must drag exactly like an unshifted one — otherwise the
8674
+ // gesture the user was told to fall back on does nothing here.
8675
+ if (button === 32 || button === 36) {
8192
8676
  this.extendMouseSelection(y, x);
8193
8677
  return;
8194
8678
  }
8195
- if (button === 0) {
8679
+ if (button === 0 || button === 4) {
8196
8680
  this.beginMouseSelection(y, x);
8197
8681
  return;
8198
8682
  }
8199
8683
  return;
8200
8684
  }
8201
8685
  // A release ends either a drag (copy what it covered) or a plain click.
8202
- if (button === 0)
8686
+ if (button === 0 || button === 4)
8203
8687
  this.endMouseSelection(y, x);
8204
8688
  return;
8205
8689
  }
@@ -8432,13 +8916,20 @@ export class SshTui {
8432
8916
  this.input = this.input.slice(0, this.cursor);
8433
8917
  this.markDirty();
8434
8918
  return;
8919
+ // A dialog owns the keyboard while it is up: moving the transcript cursor
8920
+ // under it changed what a later copy would take while the screen showed
8921
+ // nothing of the sort.
8435
8922
  case '\x0e':
8436
- this.moveCollapsibleFocus(1);
8923
+ if (this.dialog === undefined)
8924
+ this.moveFocus(1);
8437
8925
  return;
8438
8926
  case '\x10':
8439
- this.moveCollapsibleFocus(-1);
8927
+ if (this.dialog === undefined)
8928
+ this.moveFocus(-1);
8440
8929
  return;
8441
8930
  case '\x12':
8931
+ if (this.dialog !== undefined)
8932
+ return;
8442
8933
  if (this.focusedRow === null)
8443
8934
  this.toggleCollapsible();
8444
8935
  else
@@ -9612,7 +10103,11 @@ export class SshTui {
9612
10103
  }
9613
10104
  /** The painted lines as plain text, for the selection rules. */
9614
10105
  plainSelectableLines() {
9615
- return this.selectableLines.map(line => ({ text: stripAnsi(line.raw), copyable: line.copyable }));
10106
+ return this.selectableLines.map(line => ({
10107
+ text: stripAnsi(line.raw),
10108
+ copyable: line.copyable,
10109
+ gutter: line.gutter,
10110
+ }));
9616
10111
  }
9617
10112
  /** Screen row from a mouse report to an index into `selectableLines`. */
9618
10113
  selectableLineAt(screenY) {
@@ -9737,7 +10232,10 @@ export class SshTui {
9737
10232
  }
9738
10233
  copyPlainText(text, notice) {
9739
10234
  this.copyYank = text;
9740
- this.focusedRow = null;
10235
+ // The selection stays: the marker on the copied row is how the reader sees
10236
+ // *what* went to the clipboard, and a second press of the copy key must be
10237
+ // idempotent. Clearing it here made the next `/copy` silently fall back to
10238
+ // "the latest reply" — a different row, with nothing on screen to say so.
9741
10239
  this.input = '';
9742
10240
  this.cursor = 0;
9743
10241
  this.inputFolded = false;
@@ -9750,14 +10248,32 @@ export class SshTui {
9750
10248
  // how the terminals that *do* honour it work — but where the table does not
9751
10249
  // promise it the user is told once per session rather than at every copy, or
9752
10250
  // not at all until they paste into an empty clipboard.
9753
- if (!this.terminalCaps.osc52 && !this.osc52HintShown) {
10251
+ //
10252
+ // An SSH session is the exception the table cannot see. The classifier reads
10253
+ // the *remote* terminal (the tty on the far side of the connection, which is
10254
+ // usually a bare Linux console or an unnamed pty), while the bytes are
10255
+ // written to the *local* terminal — the one the user is actually copying
10256
+ // into. That local terminal is what accepted the write, which is why a copy
10257
+ // over SSH works and then warns that it did not. Warning there is a lie, so
10258
+ // the caveat stays quiet inside an SSH session.
10259
+ //
10260
+ // The test is the session itself and not `paintLink`, which is also `'ssh'`
10261
+ // on a local window whenever the launcher relayed the frame: there the
10262
+ // capability table *did* describe the terminal that receives the bytes, so
10263
+ // the caveat is exactly what the user needs to read (the terminal probe
10264
+ // asserts it does, for seven terminal profiles).
10265
+ if (!this.terminalCaps.osc52 && !this.sshSession && !this.osc52HintShown) {
9754
10266
  this.osc52HintShown = true;
9755
10267
  this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
9756
10268
  }
9757
10269
  this.markDirty();
9758
10270
  }
9759
10271
  copyFocusedCard(arg = '') {
9760
- if (this.dialog !== undefined)
10272
+ // A dialog covers the input line, so the copy key is the only way to copy
10273
+ // from inside one. The inspect overlay carries what it is showing; every
10274
+ // other dialog keeps the key inert, exactly as it always was.
10275
+ const overlay = this.dialog?.kind === 'inspect' ? this.dialog : undefined;
10276
+ if (this.dialog !== undefined && overlay === undefined)
9761
10277
  return false;
9762
10278
  // `error` copies the newest failure or diagnostic row: a long path or a
9763
10279
  // command in one of those must reach the clipboard whole, which is exactly
@@ -9775,14 +10291,30 @@ export class SshTui {
9775
10291
  return true;
9776
10292
  }
9777
10293
  if (arg.trim() === '') {
9778
- const picked = copyTextFromTranscript(this.rows, this.focusedRow);
10294
+ const shown = overlay?.copyText;
10295
+ const picked = shown !== undefined && shown.trim() !== ''
10296
+ ? { text: shown, source: 'focused' }
10297
+ : copyTextFromTranscript(this.rows, this.focusedRow);
9779
10298
  if (picked.text.trim() === '') {
9780
10299
  this.pushRow({ kind: 'system', text: t('copy.empty') });
9781
10300
  this.markDirty();
9782
10301
  return false;
9783
10302
  }
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 }));
10303
+ // The label follows the text: when the overlay supplied it, the row the
10304
+ // cursor sits on may be a different card entirely (Ctrl+N/P used to move
10305
+ // it behind the overlay), and naming that row would be a false report.
10306
+ const source = shown !== undefined && shown.trim() !== ''
10307
+ ? t('copy.sourceFull')
10308
+ : picked.source === 'focused'
10309
+ ? t(this.focusedRow?.kind === 'assistant' ? 'copy.sourceFocusedReply' : 'copy.sourceFocused')
10310
+ : t('copy.sourceAssistant');
10311
+ const notice = t('copy.ok', { chars: picked.text.length, source });
10312
+ this.copyPlainText(picked.text, notice);
10313
+ if (overlay !== undefined) {
10314
+ // The notice row is behind the overlay, so the overlay says it too.
10315
+ overlay.notice = notice;
10316
+ this.markDirty();
10317
+ }
9786
10318
  return true;
9787
10319
  }
9788
10320
  this.pushRow({ kind: 'system', text: t('copy.usage') });
@@ -9832,6 +10364,14 @@ export class SshTui {
9832
10364
  return;
9833
10365
  }
9834
10366
  this.scrollOffset = 0;
10367
+ // A selected reply has no card to fold, so Enter has to reach it too: the
10368
+ // gate below only asks about collapsible rows, and in a session with no card
10369
+ // at all (a plain Q&A, or right after `/clear`) that left Enter doing
10370
+ // nothing while `Alt+4` / Ctrl+N had just selected the reply.
10371
+ if (this.input.trim() === '' && this.focusedRow !== null && this.focusedRow.kind === 'assistant') {
10372
+ this.toggleCollapsible();
10373
+ return;
10374
+ }
9835
10375
  if (this.input.trim() === '' && this.collapsibleRows().length > 0) {
9836
10376
  this.toggleCollapsible();
9837
10377
  return;
@@ -10006,6 +10546,12 @@ export class SshTui {
10006
10546
  this.markDirty();
10007
10547
  });
10008
10548
  break;
10549
+ case 'notify':
10550
+ void this.runNotifyCommand(arg).catch((error) => {
10551
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'notify', error: errorChain(error) }) });
10552
+ this.markDirty();
10553
+ });
10554
+ break;
10009
10555
  case 'disconnect':
10010
10556
  void this.runDisconnectCommand(arg).catch((error) => {
10011
10557
  if (error instanceof UserQuestionError) {