dsh-ssh-tui 0.6.4 → 0.7.0-rc.2

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 (79) hide show
  1. package/README.en.md +14 -0
  2. package/README.md +14 -0
  3. package/docs/remote-ops.md +133 -0
  4. package/docs/screenshots/compare.png +0 -0
  5. package/docs/screenshots/headless.png +0 -0
  6. package/docs/screenshots/workspace.png +0 -0
  7. package/lib/approval-cache.js +158 -0
  8. package/lib/approval-cache.js.map +1 -0
  9. package/lib/attach.js +66 -7
  10. package/lib/attach.js.map +1 -1
  11. package/lib/auto-approval.js +13 -0
  12. package/lib/auto-approval.js.map +1 -1
  13. package/lib/color-depth.js +192 -0
  14. package/lib/color-depth.js.map +1 -0
  15. package/lib/commands.js +5 -0
  16. package/lib/commands.js.map +1 -1
  17. package/lib/copy-text.js +1 -0
  18. package/lib/copy-text.js.map +1 -1
  19. package/lib/dialogs.js +81 -8
  20. package/lib/dialogs.js.map +1 -1
  21. package/lib/display-sock.js +24 -8
  22. package/lib/display-sock.js.map +1 -1
  23. package/lib/doctor.js +380 -0
  24. package/lib/doctor.js.map +1 -0
  25. package/lib/footer.js +76 -6
  26. package/lib/footer.js.map +1 -1
  27. package/lib/i18n/en.js +136 -3
  28. package/lib/i18n/en.js.map +1 -1
  29. package/lib/i18n/zh.js +137 -4
  30. package/lib/i18n/zh.js.map +1 -1
  31. package/lib/index.js +8 -1
  32. package/lib/index.js.map +1 -1
  33. package/lib/keymap.js +172 -0
  34. package/lib/keymap.js.map +1 -0
  35. package/lib/line-diff.js +224 -0
  36. package/lib/line-diff.js.map +1 -0
  37. package/lib/line-mode.js +86 -0
  38. package/lib/line-mode.js.map +1 -0
  39. package/lib/paint.js +158 -26
  40. package/lib/paint.js.map +1 -1
  41. package/lib/plan.js +51 -8
  42. package/lib/plan.js.map +1 -1
  43. package/lib/preset-authoring.js +110 -0
  44. package/lib/preset-authoring.js.map +1 -0
  45. package/lib/preset-picker.js +105 -0
  46. package/lib/preset-picker.js.map +1 -0
  47. package/lib/preset-rows.js +288 -34
  48. package/lib/preset-rows.js.map +1 -1
  49. package/lib/selection.js +154 -0
  50. package/lib/selection.js.map +1 -0
  51. package/lib/term-text.js +106 -1
  52. package/lib/term-text.js.map +1 -1
  53. package/lib/tool-present.js +157 -21
  54. package/lib/tool-present.js.map +1 -1
  55. package/lib/tui.js +1428 -97
  56. package/lib/tui.js.map +1 -1
  57. package/lib/types/approval-cache.d.ts +125 -0
  58. package/lib/types/attach.d.ts +29 -0
  59. package/lib/types/auto-approval.d.ts +11 -0
  60. package/lib/types/color-depth.d.ts +50 -0
  61. package/lib/types/commands.d.ts +9 -0
  62. package/lib/types/dialogs.d.ts +36 -5
  63. package/lib/types/display-sock.d.ts +6 -0
  64. package/lib/types/doctor.d.ts +118 -0
  65. package/lib/types/footer.d.ts +35 -0
  66. package/lib/types/keymap.d.ts +68 -0
  67. package/lib/types/line-diff.d.ts +107 -0
  68. package/lib/types/line-mode.d.ts +36 -0
  69. package/lib/types/paint.d.ts +89 -4
  70. package/lib/types/plan.d.ts +7 -0
  71. package/lib/types/preset-authoring.d.ts +135 -0
  72. package/lib/types/preset-picker.d.ts +64 -0
  73. package/lib/types/preset-rows.d.ts +113 -9
  74. package/lib/types/selection.d.ts +81 -0
  75. package/lib/types/term-text.d.ts +29 -0
  76. package/lib/types/tool-present.d.ts +53 -3
  77. package/lib/types/transcript-types.d.ts +12 -2
  78. package/lib/types/tui.d.ts +231 -2
  79. package/package.json +33 -32
package/lib/tui.js CHANGED
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { createRequire } from 'node:module';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
+ import { METADATA_FILE, readPresetMetadata } from '@deepseek-ai/dsh-agent-presets';
25
26
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
26
27
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
27
28
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -32,10 +33,17 @@ import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js
32
33
  import { catalogContextWindow, catalogWindowIndex, suggestedRouteContextWindow, } from './context-window.js';
33
34
  import { formatFooterCwd } from './session-list.js';
34
35
  import { collectDiag, formatDiag } from './diag.js';
36
+ import { ApprovalVerdictCache, cacheableShape, verdictKey } from './approval-cache.js';
37
+ import { collectDoctor, doctorChecks, formatDoctorReport, rowsToRepair } from './doctor.js';
38
+ import { colorDepth, downgradeSgr } from './color-depth.js';
39
+ import { appendRow, lineModeEnabled, lineModeLines } from './line-mode.js';
40
+ import { keymapReport, resolveKeymap } from './keymap.js';
35
41
  import { presetLabel, profileFromArgv } from './preset-label.js';
36
- import { ensureRosterRows, rosterPatchPath } from './preset-rows.js';
42
+ import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
43
+ import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
44
+ import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup, ROSTER_ROWS, } from './preset-rows.js';
37
45
  import { SessionStatsTracker, statsRowOf } from './stats.js';
38
- import { QUESTION_OPTION_KEYS, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
46
+ import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
39
47
  import { commandSuggestions, localizedCommands } from './commands.js';
40
48
  import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
41
49
  import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
@@ -46,15 +54,16 @@ import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRem
46
54
  import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
47
55
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
48
56
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
49
- import { copyTextFromTranscript } from './copy-text.js';
57
+ import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
50
58
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
51
- import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
52
- import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
59
+ 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';
60
+ import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
61
+ 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';
53
62
  import { TerminalInputGuard } from './terminal-input.js';
54
- import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
63
+ import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
55
64
  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, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
56
65
  import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
57
- import { buildToolHeader, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
66
+ import { buildToolHeader, compactEditPath, compactFailureLines, compactFileStats, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
58
67
  export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
59
68
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
60
69
  export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
@@ -151,6 +160,38 @@ function onboardTemplate(state) {
151
160
  }
152
161
  return providerTemplates()[state.providerType];
153
162
  }
163
+ /**
164
+ * A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
165
+ * Locale-neutral on purpose — it reads the same in both catalogs.
166
+ */
167
+ /**
168
+ * The `keys` section of this plugin's settings, if the deployment has one.
169
+ *
170
+ * Read once at construction: a keymap that changed under a running session would
171
+ * move a key mid-use, which is worse than asking for a restart.
172
+ */
173
+ function readConfiguredKeys(ctx) {
174
+ const settings = ctx.get('settings');
175
+ const section = settings?.get?.(settingsNamespace('ssh-tui'));
176
+ if (section === null || typeof section !== 'object' || Array.isArray(section))
177
+ return {};
178
+ const keys = section.keys;
179
+ if (keys === null || typeof keys !== 'object' || Array.isArray(keys))
180
+ return {};
181
+ const out = {};
182
+ for (const [action, value] of Object.entries(keys)) {
183
+ if (typeof value === 'string')
184
+ out[action] = value;
185
+ }
186
+ return out;
187
+ }
188
+ function formatShortDuration(ageMs) {
189
+ const seconds = Math.max(0, Math.round(ageMs / 1000));
190
+ if (seconds < 60)
191
+ return `${seconds}s`;
192
+ const minutes = Math.round(seconds / 60);
193
+ return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`;
194
+ }
154
195
  const PLUGIN_VERSION = (() => {
155
196
  try {
156
197
  const require = createRequire(import.meta.url);
@@ -161,7 +202,11 @@ const PLUGIN_VERSION = (() => {
161
202
  return '0.0.0';
162
203
  }
163
204
  })();
205
+ /** Files named on a collapsed compact burst before the rest are counted. */
206
+ const MAX_COMPACT_FILE_STATS = 4;
164
207
  const STALL_WARNING_MS = 60000;
208
+ /** Bytes already queued for the terminal before a frame is skipped instead. */
209
+ const STDOUT_BACKLOG_BYTES = 32 * 1024;
165
210
  const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
166
211
  /**
167
212
  * How long a leftover Host that has finished its work waits, with no display,
@@ -437,6 +482,9 @@ export class SshTui {
437
482
  autoAllowedCount = 0;
438
483
  autoDeniedCount = 0;
439
484
  aiReviewCount = 0;
485
+ cacheHitCount = 0;
486
+ /** Verdicts this TUI already reviewed, reusable for a bounded time. */
487
+ approvalCache = new ApprovalVerdictCache();
440
488
  /** Host knobs folded from the session log: auto mode needs approval=ask to see requests. */
441
489
  hostSandboxMode;
442
490
  hostApprovalPolicy;
@@ -527,6 +575,26 @@ export class SshTui {
527
575
  reattachedDuringHangup = false;
528
576
  scrollOffset = 0;
529
577
  clickableRows = new Map();
578
+ /**
579
+ * The transcript as painted, for free-form selection. Only the lines that came
580
+ * from a model reply are copyable: the mouse belongs to the TUI, so dragging
581
+ * cannot reach the terminal's own selection, and this is what replaces it.
582
+ */
583
+ selectableLines = [];
584
+ /** Screen row (1-based) of the first transcript line in the last frame. */
585
+ transcriptTopScreenY = 1;
586
+ /** Where a drag started; the run it belongs to decides what can be selected. */
587
+ mouseAnchor;
588
+ /**
589
+ * What the drag grabbed, so a transcript that scrolls mid-drag cannot slide
590
+ * the selection onto a different row: the source row and the line's text.
591
+ */
592
+ mouseAnchorRef;
593
+ mouseAnchorText = '';
594
+ /** The drag being painted in reverse video right now. */
595
+ mouseSelection;
596
+ /** A press that has not moved yet: a release without motion is still a click. */
597
+ pendingMouseClick;
530
598
  paintedLinkHitsByRow = new Map();
531
599
  copyYank = '';
532
600
  /** Last OSC-52 payload (tests; empty when nothing has been copied). */
@@ -566,6 +634,41 @@ export class SshTui {
566
634
  cwdChipRow;
567
635
  /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
568
636
  forceFullPaint = false;
637
+ /** Highest row the last budgeted frame could not paint, resumed next tick. */
638
+ paintResume;
639
+ /** How many times a display has come back after a drop in this Host's life. */
640
+ displayDrops = 0;
641
+ /** When the display went away, so the reconnect can say how long it was gone. */
642
+ detachedAt;
643
+ /**
644
+ * Whether the agent-preset roster service is mounted. Read once at boot and
645
+ * after a repair — never per frame: answering it from the composition would
646
+ * mean touching the filesystem on every repaint.
647
+ */
648
+ rosterMissing = false;
649
+ /** Screen row of the health chip in the status strip, for click-to-doctor. */
650
+ healthChipRow;
651
+ /** How much colour this terminal can take (see `color-depth`). */
652
+ colorDepth;
653
+ /** Append events as plain lines instead of painting a framed screen. */
654
+ lineMode;
655
+ /** Rebindable actions (see `keymap`); defaults are only replaced on request. */
656
+ keymap;
657
+ /**
658
+ * Lines produced before a display attached.
659
+ *
660
+ * The Host boots, pushes its banner, and only then does the launcher's relay
661
+ * arrive — and a Host's stdout is a discarded pipe, so anything written in
662
+ * that window is gone. Without this buffer line mode loses the beginning of
663
+ * the session, which is the one thing a log must not do.
664
+ */
665
+ lineModePending = [];
666
+ /** The counters as they stood when the display went away, for the summary. */
667
+ awaySnapshot;
668
+ /** Approvals refused because nobody could confirm them (a subset of denials). */
669
+ detachedDeniedCount = 0;
670
+ /** Questions waiting for a display right now — their cards do not exist yet. */
671
+ queuedQuestions = 0;
569
672
  paintIntervalMs;
570
673
  paintLink = 'local';
571
674
  paintProbed = false;
@@ -584,13 +687,17 @@ export class SshTui {
584
687
  searchHits = [];
585
688
  searchIndex = -1;
586
689
  searchQuery = '';
690
+ /** The plain text the user searched for, which is what gets highlighted. */
691
+ searchNeedle = '';
587
692
  planNudgePending = false;
588
693
  pendingReveal;
589
694
  constructor(ctx, agent, config) {
590
695
  this.ctx = ctx;
591
696
  this.agent = agent;
592
- const noColorEnv = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
593
- this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
697
+ // The palette the terminal can take, decided once: a truecolor pair on an
698
+ // 8-colour terminal is not a cosmetic loss, it is the wrong colour.
699
+ this.colorDepth = config.color === false ? 'none' : colorDepth(process.env);
700
+ this.color = this.colorDepth !== 'none';
594
701
  this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
595
702
  this.showReasoning = config.showReasoning !== false;
596
703
  this.workspaceView = this.readWorkspaceView();
@@ -606,10 +713,16 @@ export class SshTui {
606
713
  this.onHangup = config.onHangup;
607
714
  this.onReattach = config.onReattach;
608
715
  this.headlessDisplay = config.headlessDisplay === true;
716
+ // Line mode: no frames at all, one appended line per event. The alternate
717
+ // screen is what makes a screen reader and a `tee` lose events, so it stays
718
+ // off too.
719
+ this.lineMode = config.lineMode === true || lineModeEnabled(process.env);
720
+ this.keymap = resolveKeymap(config.keys ?? readConfiguredKeys(this.ctx));
609
721
  this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
610
722
  this.presetId = config.presetId ?? 'standard';
611
723
  this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
612
- this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
724
+ this.useAlternateScreen = !this.lineMode
725
+ && process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
613
726
  this.paintLink = detectSshSession() ? 'ssh' : 'local';
614
727
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
615
728
  ssh: this.paintLink === 'ssh',
@@ -621,7 +734,12 @@ export class SshTui {
621
734
  // in-app update, which only runs `dsh plugin add`) boots without one. Say
622
735
  // so at boot: the banner's localized default hides the missing service, and
623
736
  // the loss is not only `/mode` — the preset-owned tools are absent too.
624
- if (this.ctx.get('agentPresets') === undefined) {
737
+ const keyReport = keymapReport(this.keymap);
738
+ if (keyReport !== undefined) {
739
+ this.pushRow({ kind: 'system', text: t('keys.report', { report: keyReport }) });
740
+ }
741
+ this.refreshRosterHealth();
742
+ if (this.rosterMissing) {
625
743
  this.pushRow({ kind: 'system', text: t('mode.bootMissing') });
626
744
  }
627
745
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
@@ -655,7 +773,16 @@ export class SshTui {
655
773
  process.stdin.prependListener('close', this.handleHangupStream);
656
774
  process.stdout.on('error', this.handleIoError);
657
775
  process.stdin.on('error', this.handleIoError);
658
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
776
+ if (this.lineMode) {
777
+ // Typing still works — commands are how the user drives the session — but
778
+ // there is no screen to paint and no animation to run.
779
+ process.stdin.setRawMode(true);
780
+ process.stdin.resume();
781
+ process.stdin.on('data', this.handleData);
782
+ this.bootBackgroundTasks();
783
+ return;
784
+ }
785
+ this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
659
786
  this.render();
660
787
  this.updateTerminalTitle();
661
788
  void this.calibratePaintInterval().finally(() => {
@@ -933,6 +1060,22 @@ export class SshTui {
933
1060
  ? this.styleLine('tool', collapsed)
934
1061
  : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
935
1062
  addDisplay(this.focusedRow === anchor && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
1063
+ // A collapsed burst is all the reader sees, so the two things that decide
1064
+ // whether to expand belong on it: which files moved, and what failed.
1065
+ if (kind === 'edits') {
1066
+ const stats = compactFileStats(groups.edits);
1067
+ const shown = stats.slice(0, MAX_COMPACT_FILE_STATS);
1068
+ const parts = shown.map(stat => `${stat.path} ${diffStatToken(stat.add, stat.del)}`.trim());
1069
+ if (stats.length > shown.length) {
1070
+ parts.push(t('compact.filesMore', { count: stats.length - shown.length }));
1071
+ }
1072
+ if (parts.length > 0) {
1073
+ addDisplay(this.styleLine('tool-result', truncateToWidth(` ${parts.join(' · ')}`, width)), anchor);
1074
+ }
1075
+ }
1076
+ for (const line of compactFailureLines(items)) {
1077
+ addDisplay(this.styleLine('error', truncateToWidth(` ${line}`, width)), anchor);
1078
+ }
936
1079
  return;
937
1080
  }
938
1081
  const expandedHeaderLines = headerSegments.length === 0
@@ -949,7 +1092,7 @@ export class SshTui {
949
1092
  ? t('compact.lines', { count: countDiffLines(item.diff) || 1 })
950
1093
  : token;
951
1094
  addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}`, width)), item);
952
- for (const line of toolBodyLines(item, Number.MAX_SAFE_INTEGER)) {
1095
+ for (const line of toolBodyLines(item, Number.MAX_SAFE_INTEGER, width)) {
953
1096
  this.paintToolBodyLine(addDisplay, item, line, width);
954
1097
  }
955
1098
  continue;
@@ -1190,7 +1333,7 @@ export class SshTui {
1190
1333
  try {
1191
1334
  process.stdout.write('\x1b]0;\x07');
1192
1335
  process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
1193
- process.stdout.write(`\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
1336
+ process.stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
1194
1337
  }
1195
1338
  catch (error) {
1196
1339
  if (!isHangupErrno(error)) {
@@ -1265,7 +1408,142 @@ export class SshTui {
1265
1408
  },
1266
1409
  ...(this.paintIntervalMs === undefined ? {} : { paintIntervalMs: this.paintIntervalMs }),
1267
1410
  });
1268
- this.pushRow({ kind: 'system', text: formatDiag(snapshot).join('\n') });
1411
+ this.pushRow({ kind: 'diag', text: formatDiag(snapshot).join('\n') });
1412
+ this.markDirty();
1413
+ }
1414
+ /**
1415
+ * Whether a host service is registered.
1416
+ *
1417
+ * The typed `ctx.get` knows only the services this plugin depends on;
1418
+ * `/doctor` also probes `codeRuntime`, which the PTC preset mounts and this
1419
+ * plugin deliberately does not depend on, so the lookup is untyped here.
1420
+ */
1421
+ hasHostService(name) {
1422
+ const get = this.ctx.get;
1423
+ return typeof get === 'function' && get.call(this.ctx, name) !== undefined;
1424
+ }
1425
+ /** Routing facts for the doctor's self-consistency check. */
1426
+ doctorRouting() {
1427
+ const settings = this.ctx.get('settings');
1428
+ if (settings === undefined)
1429
+ return undefined;
1430
+ const section = settings.get(settingsNamespace('llm-pi-ai'));
1431
+ const providers = section?.providers ?? {};
1432
+ const provider = this.currentProvider();
1433
+ const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
1434
+ const model = this.agent.options.model ?? this.selectionRef?.current?.model;
1435
+ const sub = this.subagentSelection.current;
1436
+ return {
1437
+ provider,
1438
+ ...(model === undefined ? {} : { model }),
1439
+ routes: Object.keys(providers),
1440
+ routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
1441
+ ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
1442
+ ...(sub.model === undefined ? {} : { subModel: sub.model }),
1443
+ };
1444
+ }
1445
+ /** One collected `/doctor` snapshot, from this process's own services. */
1446
+ async collectDoctorFacts(profile) {
1447
+ const routing = this.doctorRouting();
1448
+ return collectDoctor({
1449
+ profile,
1450
+ dshHome: resolveDshHome(),
1451
+ hostVersion: hostDshVersion(),
1452
+ services: {
1453
+ roster: this.ctx.get('agentPresets') !== undefined,
1454
+ codeRuntime: this.hasHostService('codeRuntime'),
1455
+ },
1456
+ anchors: [process.argv[1], this.ctx.baseUrl],
1457
+ ...(routing === undefined ? {} : { routing }),
1458
+ });
1459
+ }
1460
+ /**
1461
+ * `/doctor`: the deployment checkup. `/doctor --fix` then repairs whatever
1462
+ * the report marked fixable, after one confirmation.
1463
+ */
1464
+ async runDoctorCommand(arg, fixRequested) {
1465
+ const profile = profileFromArgv();
1466
+ const facts = await this.collectDoctorFacts(profile);
1467
+ this.pushRow({ kind: 'diag', text: formatDoctorReport(facts, doctorChecks(facts)).join('\n') });
1468
+ this.markDirty();
1469
+ if (!fixRequested)
1470
+ return;
1471
+ await this.repairProfilePatch(rowsToRepair(facts), facts);
1472
+ }
1473
+ /** `/fix <row>`: repair one named roster row of the profile patch. */
1474
+ async runFixCommand(arg) {
1475
+ const name = arg.trim().toLowerCase();
1476
+ const row = ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1477
+ if (row === undefined) {
1478
+ this.pushRow({
1479
+ kind: 'error',
1480
+ text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
1481
+ });
1482
+ this.markDirty();
1483
+ return;
1484
+ }
1485
+ await this.repairProfilePatch([row], await this.collectDoctorFacts(profileFromArgv()));
1486
+ }
1487
+ /**
1488
+ * Write the planned repairs into the profile patch after one confirmation.
1489
+ *
1490
+ * Only two things are ever changed: rows that are missing get mounted, and an
1491
+ * insert that repeats an earlier one is removed. An override or disable the
1492
+ * user wrote is never touched, and the previous file is kept as a backup.
1493
+ */
1494
+ async repairProfilePatch(rows, facts) {
1495
+ let text = facts.patch.text;
1496
+ const duplicates = facts.patch.readable ? planDuplicateRepair(text) : undefined;
1497
+ if (duplicates !== undefined)
1498
+ text = duplicates.text;
1499
+ const roster = planRosterRepair(text, rows);
1500
+ if (roster !== undefined)
1501
+ text = roster.text;
1502
+ const added = roster?.added ?? [];
1503
+ const removed = duplicates?.removed ?? [];
1504
+ if (added.length === 0 && removed.length === 0) {
1505
+ this.pushRow({ kind: 'system', text: t('doctor.fix.none') });
1506
+ this.markDirty();
1507
+ return;
1508
+ }
1509
+ const changes = [
1510
+ ...added.map(id => `+${id}`),
1511
+ ...removed.map(entry => `-${entry.id} (line ${entry.line})`),
1512
+ ].join(' ');
1513
+ const answer = await new Promise(resolve => {
1514
+ this.openConfirm(t('doctor.fix.confirm', { changes }), t('doctor.fix.confirmHint', { path: facts.patchPath }), resolve);
1515
+ });
1516
+ if (answer !== 'y') {
1517
+ this.pushRow({ kind: 'system', text: t('doctor.fix.cancelled') });
1518
+ this.markDirty();
1519
+ return;
1520
+ }
1521
+ try {
1522
+ // A patch that does not exist yet needs no backup: the write creates it
1523
+ // from the template, exactly as the installer would.
1524
+ let backup;
1525
+ if (facts.patch.readable) {
1526
+ backup = await writePatchWithBackup(facts.patchPath, text);
1527
+ }
1528
+ else {
1529
+ await ensureRosterRows(facts.dshHome, facts.profile, rows);
1530
+ }
1531
+ this.pushRow({
1532
+ kind: 'system',
1533
+ text: [
1534
+ t('doctor.fix.applied', {
1535
+ added: added.join(', ') || '—',
1536
+ removed: removed.map(entry => entry.id).join(', ') || '—',
1537
+ path: facts.patchPath,
1538
+ }),
1539
+ ...(backup === undefined ? [] : [t('doctor.fix.backup', { path: backup })]),
1540
+ t('doctor.fix.restart', { profile: facts.profile }),
1541
+ ].join('\n'),
1542
+ });
1543
+ }
1544
+ catch (error) {
1545
+ this.pushRow({ kind: 'error', text: t('doctor.fix.failed', { error: errorChain(error) }) });
1546
+ }
1269
1547
  this.markDirty();
1270
1548
  }
1271
1549
  /** Human-facing exit with goodbye and flush; called from key handling. */
@@ -1328,6 +1606,14 @@ export class SshTui {
1328
1606
  if (this.hangingUp || this.disposed)
1329
1607
  return;
1330
1608
  this.hangingUp = true;
1609
+ // The one place a real detach starts from, so the reconnect notice can say
1610
+ // how long the user was away. A boot attach never sets it.
1611
+ this.detachedAt ??= Date.now();
1612
+ this.awaySnapshot ??= {
1613
+ allowed: this.autoAllowedCount,
1614
+ denied: this.autoDeniedCount,
1615
+ detached: this.detachedDeniedCount,
1616
+ };
1331
1617
  this.reattachedDuringHangup = false;
1332
1618
  ignoreFurtherHangupSignals();
1333
1619
  this.detachDisplay();
@@ -1520,6 +1806,50 @@ export class SshTui {
1520
1806
  }
1521
1807
  this.displayHost = host;
1522
1808
  }
1809
+ /**
1810
+ * One row for what happened while nobody was watching.
1811
+ *
1812
+ * Only the parts that actually happened are listed, so a plain link blip stays
1813
+ * one line. The counts are deltas against the snapshot taken at the drop: the
1814
+ * totals belong to the session, this belongs to the gap.
1815
+ */
1816
+ pushAwaySummary(awayMs) {
1817
+ const before = this.awaySnapshot;
1818
+ this.awaySnapshot = undefined;
1819
+ if (before === undefined)
1820
+ return;
1821
+ const allowed = this.autoAllowedCount - before.allowed;
1822
+ const denied = this.autoDeniedCount - before.denied;
1823
+ const detached = this.detachedDeniedCount - before.detached;
1824
+ const waiting = this.queuedQuestions;
1825
+ const parts = [];
1826
+ if (allowed + denied > 0) {
1827
+ parts.push(t('attach.awayApprovals', { allowed, denied }));
1828
+ }
1829
+ if (detached > 0)
1830
+ parts.push(t('attach.awayDetached', { count: detached }));
1831
+ if (waiting > 0)
1832
+ parts.push(t('attach.awayWaiting', { count: waiting }));
1833
+ if (parts.length === 0)
1834
+ return;
1835
+ this.pushRow({
1836
+ kind: 'system',
1837
+ text: t('attach.awaySummary', { away: formatShortDuration(awayMs), parts: parts.join(' · ') }),
1838
+ });
1839
+ }
1840
+ /** Say the user was away, and what happened while they were. */
1841
+ pushReconnectNotice() {
1842
+ if (this.detachedAt === undefined)
1843
+ return;
1844
+ this.displayDrops += 1;
1845
+ const away = Date.now() - this.detachedAt;
1846
+ this.detachedAt = undefined;
1847
+ this.pushRow({
1848
+ kind: 'system',
1849
+ text: t('attach.reconnected', { count: this.displayDrops, away: formatShortDuration(away) }),
1850
+ });
1851
+ this.pushAwaySummary(away);
1852
+ }
1523
1853
  /** Re-open DECSET and start painting to an attached Display relay. */
1524
1854
  attachRelayDisplay() {
1525
1855
  // A relay that HELLOs while a hangup is still unwinding must be honored by
@@ -1540,9 +1870,22 @@ export class SshTui {
1540
1870
  this.lastPaintRows = [];
1541
1871
  this.lastChromeKey = '';
1542
1872
  this.lastTranscriptStart = -1;
1543
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
1873
+ if (this.lineMode) {
1874
+ // No DECSET at all in line mode: no mouse reporting, no hidden cursor, no
1875
+ // alternate screen. The held lines are the whole point of the handover.
1876
+ this.flushLineMode();
1877
+ // The user is back, and the notice belongs in the log like any event.
1878
+ this.pushReconnectNotice();
1879
+ void this.onReattach?.();
1880
+ return;
1881
+ }
1882
+ this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
1544
1883
  this.forceFullPaint = true;
1545
1884
  this.dirty = true;
1885
+ // The user is back: say so, with how many times this Host has been
1886
+ // reconnected and how long this gap lasted. Pushed before the paint so the
1887
+ // frame that follows carries it.
1888
+ this.pushReconnectNotice();
1546
1889
  this.paint();
1547
1890
  this.startRenderTimer();
1548
1891
  void this.onReattach?.();
@@ -1595,6 +1938,14 @@ export class SshTui {
1595
1938
  /** Append one transcript row, bounding memory on long sessions. */
1596
1939
  pushRow(row) {
1597
1940
  this.rows.push(row);
1941
+ if (this.lineMode) {
1942
+ // Appended here rather than from the render timer: a timer can coalesce
1943
+ // two events into one tick, and an event that is coalesced away is an
1944
+ // event the log lost.
1945
+ const appended = appendRow(lineModeLines(row));
1946
+ if (appended !== '')
1947
+ this.writeLineMode(appended);
1948
+ }
1598
1949
  // Locate the focused card before trimming: after the splice every surviving
1599
1950
  // index shifts down and a stale index would clear the focus by accident.
1600
1951
  const focusedIndex = this.focusedRow === null || this.focusedRow.kind === 'streaming-reasoning'
@@ -1606,6 +1957,21 @@ export class SshTui {
1606
1957
  if (focusedIndex !== undefined && focusedIndex < removed)
1607
1958
  this.focusedRow = null;
1608
1959
  }
1960
+ /** Append one event, holding it until a display can carry it. */
1961
+ writeLineMode(chunk) {
1962
+ if (this.displayHost?.attached === true && !this.displayDetached) {
1963
+ this.write(chunk);
1964
+ return;
1965
+ }
1966
+ this.lineModePending.push(chunk);
1967
+ }
1968
+ /** Hand the held lines to the display that just attached. */
1969
+ flushLineMode() {
1970
+ if (this.lineModePending.length === 0)
1971
+ return;
1972
+ const pending = this.lineModePending.splice(0);
1973
+ this.write(pending.join(''));
1974
+ }
1609
1975
  /** The transcript rows that support per-row expand/collapse. */
1610
1976
  collapsibleRows() {
1611
1977
  const compact = this.isCompactView();
@@ -1815,15 +2181,78 @@ export class SshTui {
1815
2181
  }
1816
2182
  return lines;
1817
2183
  }
2184
+ /**
2185
+ * One diff line with its changed characters in reverse video.
2186
+ *
2187
+ * The line is split at the spans and each piece is styled on its own. Reverse
2188
+ * is an attribute rather than a colour, so the emphasis survives every palette,
2189
+ * including a monochrome terminal.
2190
+ */
2191
+ styleEmphasisedLine(line, width) {
2192
+ const inner = Math.max(1, width - 2);
2193
+ const spans = [...(line.spans ?? [])].sort((left, right) => left.start - right.start);
2194
+ const pieces = [];
2195
+ let cursor = 0;
2196
+ for (const span of spans) {
2197
+ const from = Math.max(cursor, Math.min(line.text.length, span.start));
2198
+ const to = Math.max(from, Math.min(line.text.length, span.end));
2199
+ if (from > cursor)
2200
+ pieces.push({ text: line.text.slice(cursor, from), emphasis: false });
2201
+ if (to > from)
2202
+ pieces.push({ text: line.text.slice(from, to), emphasis: true });
2203
+ cursor = to;
2204
+ }
2205
+ if (cursor < line.text.length)
2206
+ pieces.push({ text: line.text.slice(cursor), emphasis: false });
2207
+ const flat = pieces.length === 0 ? [{ text: line.text, emphasis: false }] : pieces;
2208
+ const rows = [];
2209
+ let buffer = [];
2210
+ let used = 0;
2211
+ const flush = () => {
2212
+ if (buffer.length === 0)
2213
+ return;
2214
+ const body = buffer
2215
+ .map(piece => (piece.emphasis
2216
+ ? `\x1b[7m${this.styleLine(line.kind, piece.text)}\x1b[27m`
2217
+ : this.styleLine(line.kind, piece.text)))
2218
+ .join('');
2219
+ rows.push(` ${body}`);
2220
+ buffer = [];
2221
+ used = 0;
2222
+ };
2223
+ for (const piece of flat) {
2224
+ let rest = piece.text;
2225
+ while (rest !== '') {
2226
+ const room = Math.max(1, inner - used);
2227
+ const wrapped = wrap(rest, room)[0] ?? rest;
2228
+ buffer.push({ text: wrapped, emphasis: piece.emphasis });
2229
+ used += displayWidth(wrapped);
2230
+ rest = rest.slice(wrapped.length);
2231
+ if (used >= inner && rest !== '')
2232
+ flush();
2233
+ }
2234
+ }
2235
+ flush();
2236
+ return rows.length === 0 ? [this.styleLine(line.kind, '')] : rows;
2237
+ }
1818
2238
  paintToolBodyLine(addDisplay, row, line, width) {
1819
2239
  const inner = Math.max(1, width - 2);
1820
2240
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
2241
+ // An emphasised line is styled in parts: the escapes wrap *styled* pieces,
2242
+ // because the painter's sanitiser strips any escape that reaches it inside
2243
+ // the text — which is how a literal `[7m` once appeared on screen.
2244
+ if (line.spans !== undefined && line.spans.length > 0) {
2245
+ for (const painted of this.styleEmphasisedLine(line, width))
2246
+ addDisplay(painted, row);
2247
+ return;
2248
+ }
1821
2249
  for (const wrapped of wrap(line.text, inner)) {
1822
2250
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
1823
2251
  const kind = line.kind;
1824
2252
  const style = kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
1825
2253
  ? this.styleLine(kind, body)
1826
2254
  : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
2255
+ || kind === 'todo-failed' || kind === 'todo-skipped'
1827
2256
  ? this.styleLine(kind, body)
1828
2257
  : kind === 'error'
1829
2258
  ? this.styleLine('error', body)
@@ -1856,6 +2285,7 @@ export class SshTui {
1856
2285
  rendered.push(kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
1857
2286
  ? this.styleLine(kind, body)
1858
2287
  : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
2288
+ || kind === 'todo-failed' || kind === 'todo-skipped'
1859
2289
  ? this.styleLine(kind, body)
1860
2290
  : kind === 'error'
1861
2291
  ? this.styleLine('error', body)
@@ -2008,10 +2438,42 @@ export class SshTui {
2008
2438
  this.forceFullPaint = true;
2009
2439
  this.markDirty();
2010
2440
  }
2011
- highlightSearchLine(line) {
2012
- if (line.includes('\x1b[7m'))
2013
- return line;
2014
- return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
2441
+ /**
2442
+ * Mark the search hit across the lines that are actually on screen.
2443
+ *
2444
+ * Only the lines that show the match are marked — highlighting the whole card
2445
+ * made a hit look like a selected card, and on a narrow screen the reader
2446
+ * could not tell which word had matched. When the wrap split the needle so no
2447
+ * single line contains it, the row's first line is marked instead: `/find`
2448
+ * reported a hit, so the reader has to see where it is.
2449
+ */
2450
+ highlightSearchLines(visible, refs) {
2451
+ const hit = this.searchHits[this.searchIndex];
2452
+ const needle = this.searchNeedle;
2453
+ if (hit === undefined || needle === '')
2454
+ return [...visible];
2455
+ const hitLines = [];
2456
+ for (let index = 0; index < visible.length; index += 1) {
2457
+ if (refs[index] === hit)
2458
+ hitLines.push(index);
2459
+ }
2460
+ if (hitLines.length === 0)
2461
+ return [...visible];
2462
+ const inlineHit = hitLines.some(index => searchContains(stripAnsi(visible[index] ?? ''), needle));
2463
+ const lines = [...visible];
2464
+ for (const index of hitLines) {
2465
+ const line = lines[index] ?? '';
2466
+ if (line.includes('\x1b[7m'))
2467
+ continue;
2468
+ if (searchContains(stripAnsi(line), needle)) {
2469
+ lines[index] = this.color ? highlightAnsiNeedle(line, needle) : `» ${line}`;
2470
+ continue;
2471
+ }
2472
+ if (!inlineHit && index === hitLines[0]) {
2473
+ lines[index] = this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
2474
+ }
2475
+ }
2476
+ return lines;
2015
2477
  }
2016
2478
  revealRow(row) {
2017
2479
  if (row === undefined)
@@ -2094,6 +2556,8 @@ export class SshTui {
2094
2556
  const parsed = parseFindQuery(arg);
2095
2557
  const label = parsed.category === undefined ? '' : `${cardCategoryLabel(parsed.category)} `;
2096
2558
  const hits = matchTranscriptRows(this.rows, arg);
2559
+ // The step message says "回复 deploy"; the highlight must look for "deploy".
2560
+ this.searchNeedle = parsed.category === undefined ? arg.trim() : parsed.query;
2097
2561
  this.applySearchHits(`${label}${parsed.query}`.trim(), hits);
2098
2562
  }
2099
2563
  stepSearch(delta) {
@@ -2118,7 +2582,7 @@ export class SshTui {
2118
2582
  this.revealRow(hit);
2119
2583
  }
2120
2584
  paint = () => {
2121
- if (this.exiting)
2585
+ if (this.exiting || this.lineMode)
2122
2586
  return;
2123
2587
  const width = Math.max(10, this.screenColumns());
2124
2588
  const height = Math.max(6, this.screenRows());
@@ -2130,9 +2594,7 @@ export class SshTui {
2130
2594
  const displayRefs = [];
2131
2595
  const searchHit = this.searchHits[this.searchIndex];
2132
2596
  const addDisplay = (line, ref) => {
2133
- const clipped = clipAnsiToWidth(line, width);
2134
- const hit = ref !== undefined && ref === searchHit;
2135
- display.push(hit ? this.highlightSearchLine(clipped) : clipped);
2597
+ display.push(clipAnsiToWidth(line, width));
2136
2598
  displayRefs.push(ref);
2137
2599
  };
2138
2600
  const pushRow = (kind, text, ref) => {
@@ -2234,9 +2696,17 @@ export class SshTui {
2234
2696
  for (const wrapped of expandedHeaderLines) {
2235
2697
  addDisplay(wrapped, row);
2236
2698
  }
2237
- for (const line of toolBodyLines(row, Number.MAX_SAFE_INTEGER)) {
2699
+ // On a measured slow link an expanded body is capped and points at the
2700
+ // overlay instead; every other link shows it in full, exactly as it has
2701
+ // since the 0.3.9 card pass.
2702
+ const bodyLimit = toolBodyLineLimit(linkQualityOf(this.paintLink, this.paintRttMs));
2703
+ const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER, width);
2704
+ for (const line of body.slice(0, bodyLimit)) {
2238
2705
  this.paintToolBodyLine(addDisplay, row, line, width);
2239
2706
  }
2707
+ if (body.length > bodyLimit) {
2708
+ addDisplay(this.styleLine('tool-result', truncateToWidth(t('tool.bodyMoreLines', { count: body.length - bodyLimit }), width)), row);
2709
+ }
2240
2710
  continue;
2241
2711
  }
2242
2712
  if (row.kind === 'subagent') {
@@ -2585,11 +3055,22 @@ export class SshTui {
2585
3055
  }
2586
3056
  const options = d.question.options ?? [];
2587
3057
  const approve = d.question.intent?.approve;
2588
- const start = pickerWindowStart(d.cursor, options.length);
2589
- const end = Math.min(options.length, start + PICKER_WINDOW);
3058
+ if (d.filtering === true || (d.filter ?? '') !== '') {
3059
+ addDialog(` ${t('dialog.filterLabel', { query: d.filter ?? '' })}`);
3060
+ }
3061
+ // Only the options the filter left are listed. The hotkey stays the
3062
+ // option's own number in the full list, so it does not move under the
3063
+ // user's fingers as the filter narrows.
3064
+ const visibleIndexes = visibleQuestionIndexes(d);
3065
+ const cursorAt = Math.max(0, visibleIndexes.indexOf(d.cursor));
3066
+ const start = pickerWindowStart(cursorAt, visibleIndexes.length);
3067
+ const end = Math.min(visibleIndexes.length, start + PICKER_WINDOW);
2590
3068
  if (start > 0)
2591
3069
  addDialog(` ${t('picker.moreAbove', { count: start })}`);
2592
- for (let index = start; index < end; index += 1) {
3070
+ for (let at = start; at < end; at += 1) {
3071
+ const index = visibleIndexes[at];
3072
+ if (index === undefined)
3073
+ continue;
2593
3074
  const option = options[index];
2594
3075
  if (option === undefined)
2595
3076
  continue;
@@ -2600,11 +3081,15 @@ export class SshTui {
2600
3081
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
2601
3082
  addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
2602
3083
  }
2603
- if (end < options.length)
2604
- addDialog(` ${t('picker.moreBelow', { count: options.length - end })}`);
3084
+ if (end < visibleIndexes.length) {
3085
+ addDialog(` ${t('picker.moreBelow', { count: visibleIndexes.length - end })}`);
3086
+ }
2605
3087
  if (options.length === 0) {
2606
3088
  addDialog(t('dialog.freeform'));
2607
3089
  }
3090
+ if (d.filtering === true) {
3091
+ addDialog(t('dialog.filterHint'));
3092
+ }
2608
3093
  addDialog(d.question.multiSelect === true ? t('dialog.multiHint') : t('dialog.singleHint'));
2609
3094
  }
2610
3095
  }
@@ -2697,6 +3182,16 @@ export class SshTui {
2697
3182
  const visibleRefs = window.visibleRefs;
2698
3183
  this.clickableRows.clear();
2699
3184
  this.paintedLinkHitsByRow.clear();
3185
+ this.transcriptTopScreenY = headerLines.length + 1;
3186
+ // Kept as painted and stripped only when a drag actually happens: this
3187
+ // runs on every frame, and a mouse event is rare next to a repaint.
3188
+ this.selectableLines = visible.map((line, index) => ({
3189
+ raw: line,
3190
+ ref: visibleRefs[index],
3191
+ // Only a model reply is freely copyable; a tool card, a notice or the
3192
+ // chrome keeps its click behavior instead.
3193
+ copyable: visibleRefs[index]?.kind === 'assistant',
3194
+ }));
2700
3195
  for (let index = 0; index < visibleRefs.length; index++) {
2701
3196
  const ref = visibleRefs[index];
2702
3197
  const screenY = headerLines.length + index + 1;
@@ -2706,6 +3201,10 @@ export class SshTui {
2706
3201
  if (hits.length > 0)
2707
3202
  this.paintedLinkHitsByRow.set(screenY, hits);
2708
3203
  }
3204
+ const visibleWithSearch = this.highlightSearchLines(visible, visibleRefs);
3205
+ const visibleWithSelection = this.mouseSelection === undefined
3206
+ ? visibleWithSearch
3207
+ : this.highlightSelection(visibleWithSearch);
2709
3208
  const dockPlan = this.findLivePlanRow();
2710
3209
  if (dockPlan !== undefined && planDockLines.length > 0) {
2711
3210
  const dockTop = headerLines.length + visible.length + 1;
@@ -2713,11 +3212,6 @@ export class SshTui {
2713
3212
  }
2714
3213
  const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
2715
3214
  const statsGroups = footerStatsGroups(statsRowOf(this.statsTracker.snapshot()));
2716
- const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
2717
- const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
2718
- const statsLine = statsPlain.startsWith(chipVisible)
2719
- ? clipAnsiToWidth(`${linkChip}${this.styleLine('system', statsPlain.slice(chipVisible.length))}`, Math.max(1, width))
2720
- : this.styleLine('system', statsPlain);
2721
3215
  const idleMs = Date.now() - this.lastActivity;
2722
3216
  const livePlan = this.findLivePlanRow();
2723
3217
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
@@ -2754,6 +3248,10 @@ export class SshTui {
2754
3248
  subModel: sub.model,
2755
3249
  ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
2756
3250
  ...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
3251
+ // Quota and context pressure stay on the identity line, where they have
3252
+ // always been: B-1 moved them one row up into the strip, and a user
3253
+ // looking at a wide terminal read that as "额度条没了". The strip keeps
3254
+ // the loss order for the groups it does own.
2757
3255
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
2758
3256
  ? {}
2759
3257
  : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
@@ -2778,12 +3276,37 @@ export class SshTui {
2778
3276
  : activity.text;
2779
3277
  const identity = footerIdentityParts(footer);
2780
3278
  const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
3279
+ // The context ring is the one accent on an otherwise muted line; the mute
3280
+ // has to be resumed after its reset, exactly as the pre-strip footer did.
2781
3281
  const statusLine = this.contextPressure === undefined || !this.color
2782
3282
  ? this.styleLine('system', statusText)
2783
3283
  : this.styleLine('system', statusText).replace(formatContextPressureRing(this.contextPressure.percent), `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${formatContextPressureRing(this.contextPressure.percent)}\x1b[0m\x1b[90m`);
3284
+ // The strip: one loss order for the groups a narrow terminal can do without.
3285
+ // Health leads because it is the only group that reports a broken install;
3286
+ // the session counters go before the operational signals. Quota and context
3287
+ // pressure are deliberately not here — they are on the identity line.
3288
+ const strip = [];
3289
+ const health = footerHealthChip(this.rosterMissing, this.color);
3290
+ // Muted like the identity line, accent excepted: the pre-strip footer
3291
+ // painted the counters dim and only the ⚠'s own glyph yellow.
3292
+ if (health !== undefined)
3293
+ strip.push({ ...health, long: this.muteFooterLine(health.long) });
3294
+ strip.push({
3295
+ id: 'link',
3296
+ // Keeps the default foreground, exactly as it looked before the strip.
3297
+ long: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color),
3298
+ short: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false)
3299
+ .replace(/\s*\d+ms$/u, ''),
3300
+ priority: 1,
3301
+ });
3302
+ statsGroups.forEach((text, index) => {
3303
+ strip.push({ id: `stat${index}`, long: this.muteFooterLine(text), short: '', priority: 4 + index });
3304
+ });
3305
+ const statsLine = clipAnsiToWidth(fitFooterChips(strip, Math.max(1, width), this.mutedSeparator()), Math.max(1, width));
3306
+ this.healthChipRow = undefined;
2784
3307
  const paintRows = [
2785
3308
  ...headerLines,
2786
- ...visible,
3309
+ ...visibleWithSelection,
2787
3310
  ...planDockLines,
2788
3311
  ...dialogLines,
2789
3312
  inputDivider,
@@ -2792,6 +3315,8 @@ export class SshTui {
2792
3315
  `${statsLine}\x1b[0m`,
2793
3316
  `${statusLine}\x1b[0m`,
2794
3317
  ];
3318
+ if (health !== undefined)
3319
+ this.healthChipRow = paintRows.length - 1;
2795
3320
  // Bottom chrome is force-repainted whenever its state changes while the
2796
3321
  // agent is working; this clears any stale cell left behind by a previous
2797
3322
  // frame even when the row strings happen to be identical.
@@ -2800,7 +3325,7 @@ export class SshTui {
2800
3325
  this.status,
2801
3326
  this.agent.status,
2802
3327
  this.scrollOffset,
2803
- statsPlain,
3328
+ statsLine,
2804
3329
  statusText,
2805
3330
  inputView.text,
2806
3331
  inputView.folded,
@@ -2826,10 +3351,13 @@ export class SshTui {
2826
3351
  this.forceFullPaint = false;
2827
3352
  // One stdout write per frame: dirty rows only, so jump-host SSH sees a
2828
3353
  // single packet instead of one write per line. Clip/pad so leftover
2829
- // wide glyphs cannot wrap into the input box.
3354
+ // wide glyphs cannot wrap into the input box. The frame is bounded by the
3355
+ // link's byte budget: the tail goes first and rows that do not fit stay
3356
+ // dirty for the next tick (tracked through `paintResume`), so a big repaint
3357
+ // arrives in a few ordered pieces instead of one long freeze.
2830
3358
  const inputTopRow = visible.length + planDockLines.length + dialogLines.length + suggestionLines.length + headerLines.length + 2;
2831
3359
  const row = Math.min(height, inputTopRow + cursorRowOffset);
2832
- this.write(composePaintOutput({
3360
+ const frame = composePaintFrame({
2833
3361
  width,
2834
3362
  height,
2835
3363
  paintRows,
@@ -2840,10 +3368,22 @@ export class SshTui {
2840
3368
  previousChromeStart: this.lastChromeStart,
2841
3369
  cursorRow: row,
2842
3370
  cursorColumn: column,
2843
- }));
3371
+ // A size change starts with a full clear, so splitting it would leave the
3372
+ // user looking at a half-empty screen for a frame or two; that one frame
3373
+ // stays whole. Everything incremental is budgeted.
3374
+ maxBytes: sizeChanged ? undefined : frameByteBudget(linkQualityOf(this.paintLink, this.paintRttMs)),
3375
+ ...(this.paintResume === undefined ? {} : { from: this.paintResume }),
3376
+ });
3377
+ this.paintResume = frame.resume;
3378
+ this.write(frame.output);
2844
3379
  this.lastPaintCursorRow = row;
2845
3380
  this.lastPaintCursorColumn = Math.min(width, Math.max(1, column));
2846
- this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
3381
+ const snapshot = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
3382
+ // Only the rows this frame wrote are up to date; a deferred row keeps its
3383
+ // old entry so the next frame still sees it as changed.
3384
+ this.lastPaintRows = frame.deferred.length === 0
3385
+ ? snapshot
3386
+ : advancePaintedRows(this.lastPaintRows, snapshot, frame.painted);
2847
3387
  this.lastChromeKey = chromeKey;
2848
3388
  this.lastPaintWidth = width;
2849
3389
  this.lastPaintHeight = height;
@@ -2958,6 +3498,13 @@ export class SshTui {
2958
3498
  return;
2959
3499
  if ((this.displayDetached || this.headlessDisplay) && this.displayHost?.attached !== true)
2960
3500
  return;
3501
+ // Burst merging on a slow link: the previous frames have not drained yet,
3502
+ // so another one would only queue behind them and delay the newest state.
3503
+ // The paint is skipped, not lost — `dirty` stays set and the next tick
3504
+ // draws what is current. The bound keeps a genuinely stuck pipe from
3505
+ // freezing the screen forever: after four cadences we paint regardless.
3506
+ if (this.stdoutBacklogged() && Date.now() - this.lastPaintAt < 4 * this.paintIntervalMs)
3507
+ return;
2961
3508
  if (this.agent.status === 'running'
2962
3509
  && Date.now() - this.lastActivity > STALL_WARNING_MS
2963
3510
  && !this.stalledWarningShown
@@ -2971,11 +3518,423 @@ export class SshTui {
2971
3518
  this.dirty = false;
2972
3519
  this.paint();
2973
3520
  };
3521
+ /** Whether the previous frames have not drained yet (slow link, big burst). */
3522
+ stdoutBacklogged() {
3523
+ // Only the direct-stdout path can be measured here; a detached display
3524
+ // sends through its own socket and keeps its own queue.
3525
+ if (this.displayHost?.attached === true || this.displayDetached)
3526
+ return false;
3527
+ try {
3528
+ return process.stdout.writableLength > STDOUT_BACKLOG_BYTES;
3529
+ }
3530
+ catch {
3531
+ return false;
3532
+ }
3533
+ }
3534
+ /**
3535
+ * `/preset`: list, inspect, copy, rename, describe, and delete agent presets.
3536
+ *
3537
+ * The four operations the web client drives over the remote surface (list,
3538
+ * read, copy, delete) plus display-metadata edits, which upstream has no
3539
+ * method for and this command writes itself. Every write is either an
3540
+ * upstream service call — which validates the id, the trust level, and the
3541
+ * writable root — or a `preset.yml` written beside a backup. Nothing here
3542
+ * throws: refusals and host errors become rows.
3543
+ */
3544
+ async runPresetCommand(arg) {
3545
+ const service = this.presetService();
3546
+ if (service === undefined) {
3547
+ this.pushRow({
3548
+ kind: 'error',
3549
+ text: [
3550
+ t('preset.missingService'),
3551
+ t('mode.missingServiceHint', {
3552
+ profile: profileFromArgv(),
3553
+ patch: rosterPatchPath(resolveDshHome(), profileFromArgv()),
3554
+ }),
3555
+ ].join('\n'),
3556
+ });
3557
+ this.markDirty();
3558
+ return;
3559
+ }
3560
+ const parts = arg.trim().split(/\s+/u).filter(part => part !== '');
3561
+ // No argument opens the wizard: pick a preset, then pick what to do with
3562
+ // it. Every action it offers calls the same handler the line command does,
3563
+ // so both paths share one set of plans, refusals, and confirmations.
3564
+ if (parts.length === 0)
3565
+ return this.presetWizard(service);
3566
+ const sub = parts[0] ?? 'list';
3567
+ const rest = parts.slice(1);
3568
+ if (sub === 'list')
3569
+ return this.presetList(service);
3570
+ if (sub === 'show')
3571
+ return this.presetShow(service, rest[0]);
3572
+ if (sub === 'copy')
3573
+ return this.presetCopy(service, rest[0], rest[1], rest.slice(2).join(' '));
3574
+ if (sub === 'rename')
3575
+ return this.presetWriteMetadata(service, rest[0], { name: rest.slice(1).join(' ') });
3576
+ if (sub === 'describe')
3577
+ return this.presetWriteMetadata(service, rest[0], { description: rest.slice(1).join(' ') });
3578
+ if (sub === 'delete')
3579
+ return this.presetDelete(service, rest[0]);
3580
+ this.pushRow({ kind: 'error', text: t('preset.unknownSub', { sub }) });
3581
+ this.markDirty();
3582
+ }
3583
+ /** The preset service, or undefined on a profile without the roster. */
3584
+ presetService() {
3585
+ return this.ctx.get('agentPresets');
3586
+ }
3587
+ /**
3588
+ * The interactive path: pick a preset, then pick what to do with it.
3589
+ *
3590
+ * Built only on the dialogs the rest of the TUI already uses — a list with a
3591
+ * preselected current entry, and a question with no options for typed input —
3592
+ * and it hands every action to the same handler the line command calls, so a
3593
+ * wizard write can never take a shortcut past a plan, a refusal, or the
3594
+ * delete confirmation.
3595
+ */
3596
+ async presetWizard(service) {
3597
+ try {
3598
+ const presets = await service.list();
3599
+ if (presets.length === 0) {
3600
+ this.pushRow({ kind: 'error', text: t('preset.none') });
3601
+ this.markDirty();
3602
+ return;
3603
+ }
3604
+ const labelOf = (preset) => `${preset.id} · ${preset.name ?? preset.id}`;
3605
+ const currentIndex = presets.findIndex(preset => preset.id === this.presetId);
3606
+ const pickedAnswer = await this.askQuestion({
3607
+ id: 'preset-pick',
3608
+ question: t('preset.wizardPick', { count: presets.length }),
3609
+ options: presets.map(preset => ({
3610
+ label: labelOf(preset),
3611
+ description: [
3612
+ preset.trust === 'user' ? t('preset.trustUser') : t('preset.trustSystem'),
3613
+ ...(preset.id === service.defaultId ? [t('preset.isDefault')] : []),
3614
+ ...(preset.id === this.presetId ? [t('preset.isCurrent')] : []),
3615
+ ...(preset.broken === undefined ? [] : [t('preset.wizardBroken')]),
3616
+ ].join(' · '),
3617
+ })),
3618
+ }, 0, 1, currentIndex >= 0 ? currentIndex : 0);
3619
+ const picked = presets.find(preset => labelOf(preset) === pickedAnswer.selected[0]);
3620
+ if (picked === undefined)
3621
+ return;
3622
+ const authorable = service.authorable === true;
3623
+ const userOwned = picked.trust === 'user';
3624
+ const actions = [
3625
+ { id: 'show', label: t('preset.actionShow') },
3626
+ // A copy may start from any preset — the destination is the user root.
3627
+ ...(authorable ? [{ id: 'copy', label: t('preset.actionCopy') }] : []),
3628
+ ...(authorable && userOwned ? [
3629
+ { id: 'rename', label: t('preset.actionRename') },
3630
+ { id: 'describe', label: t('preset.actionDescribe') },
3631
+ { id: 'delete', label: t('preset.actionDelete') },
3632
+ ] : []),
3633
+ ];
3634
+ const actionAnswer = await this.askQuestion({
3635
+ id: 'preset-action',
3636
+ question: t('preset.wizardAction', { id: picked.id, name: picked.name ?? picked.id }),
3637
+ options: actions.map(action => ({ label: action.label })),
3638
+ });
3639
+ const action = actions.find(candidate => candidate.label === actionAnswer.selected[0])?.id;
3640
+ if (action === undefined)
3641
+ return;
3642
+ if (action === 'show') {
3643
+ await this.presetShow(service, picked.id);
3644
+ return;
3645
+ }
3646
+ if (action === 'copy') {
3647
+ const id = await this.askPresetInput(t('preset.wizardNewId', { from: picked.id }));
3648
+ if (id === undefined)
3649
+ return this.presetCancelled();
3650
+ const name = await this.askPresetInput(t('preset.wizardNewName'));
3651
+ if (name === undefined)
3652
+ return this.presetCancelled();
3653
+ await this.presetCopy(service, picked.id, id.trim(), name.trim());
3654
+ return;
3655
+ }
3656
+ if (action === 'rename') {
3657
+ const name = await this.askPresetInput(t('preset.wizardRename', { id: picked.id }));
3658
+ if (name === undefined)
3659
+ return this.presetCancelled();
3660
+ await this.presetWriteMetadata(service, picked.id, { name });
3661
+ return;
3662
+ }
3663
+ if (action === 'describe') {
3664
+ const description = await this.askPresetInput(t('preset.wizardDescribe', { id: picked.id }));
3665
+ if (description === undefined)
3666
+ return this.presetCancelled();
3667
+ await this.presetWriteMetadata(service, picked.id, { description });
3668
+ return;
3669
+ }
3670
+ await this.presetDelete(service, picked.id);
3671
+ }
3672
+ catch (error) {
3673
+ if (error instanceof UserQuestionError) {
3674
+ this.presetCancelled();
3675
+ return;
3676
+ }
3677
+ throw error;
3678
+ }
3679
+ }
3680
+ /** One free-form answer; `undefined` when the user cancels with Esc. */
3681
+ async askPresetInput(question) {
3682
+ try {
3683
+ const answer = await this.askQuestion({ id: 'preset-input', question, options: [] });
3684
+ return answer.custom ?? answer.selected[0] ?? '';
3685
+ }
3686
+ catch (error) {
3687
+ if (error instanceof UserQuestionError)
3688
+ return undefined;
3689
+ throw error;
3690
+ }
3691
+ }
3692
+ /** A wizard step the user backed out of: nothing was written. */
3693
+ presetCancelled() {
3694
+ this.pushRow({ kind: 'system', text: t('preset.cancelled') });
3695
+ this.markDirty();
3696
+ }
3697
+ /** One row per preset: id, display name, trust, and what it is missing. */
3698
+ async presetList(service) {
3699
+ const presets = await service.list();
3700
+ const authorable = service.authorable === true;
3701
+ const lines = [t('preset.listTitle', { count: presets.length, default: service.defaultId })];
3702
+ for (const preset of presets) {
3703
+ const flags = [
3704
+ preset.trust === 'user' ? t('preset.trustUser') : t('preset.trustSystem'),
3705
+ ...(preset.id === service.defaultId ? [t('preset.isDefault')] : []),
3706
+ ...(preset.id === this.presetId ? [t('preset.isCurrent')] : []),
3707
+ ].join(' · ');
3708
+ lines.push(t('preset.listRow', { id: preset.id, name: preset.name ?? preset.id, flags }));
3709
+ if (preset.broken !== undefined)
3710
+ lines.push(t('preset.listBroken', { reason: preset.broken }));
3711
+ }
3712
+ lines.push(authorable
3713
+ ? t('preset.listWritable', { root: service.roots?.find(root => root.trust === 'user')?.path ?? '' })
3714
+ : t('preset.listReadOnly'));
3715
+ this.pushRow({ kind: 'system', text: lines.join('\n') });
3716
+ this.markDirty();
3717
+ }
3718
+ /** One preset's metadata and the rows its composition mounts. */
3719
+ async presetShow(service, id) {
3720
+ const presets = await service.list();
3721
+ const ids = presets.map(preset => preset.id).join(', ');
3722
+ if (id === undefined || id === '') {
3723
+ this.pushRow({ kind: 'error', text: t('preset.needId', { ids }) });
3724
+ this.markDirty();
3725
+ return;
3726
+ }
3727
+ const preset = presets.find(candidate => candidate.id === id);
3728
+ if (preset === undefined) {
3729
+ this.pushRow({ kind: 'error', text: t('preset.unknownId', { id, ids }) });
3730
+ this.markDirty();
3731
+ return;
3732
+ }
3733
+ const lines = [t('preset.showTitle', { id: preset.id, name: preset.name ?? preset.id })];
3734
+ lines.push(t('preset.showMeta', {
3735
+ trust: preset.trust === 'user' ? t('preset.trustUser') : t('preset.trustSystem'),
3736
+ order: preset.order === undefined ? '—' : String(preset.order),
3737
+ directory: presetDirectory(preset),
3738
+ }));
3739
+ if (preset.description !== undefined)
3740
+ lines.push(t('preset.showDescription', { description: preset.description }));
3741
+ if (preset.broken !== undefined) {
3742
+ lines.push(t('preset.showBroken', { reason: preset.broken }));
3743
+ }
3744
+ else if (typeof service.compositionInventory === 'function') {
3745
+ const composition = (await service.compositionInventory()).find(entry => entry.id === preset.id);
3746
+ if (composition === undefined) {
3747
+ lines.push(t('preset.showNoRows'));
3748
+ }
3749
+ else {
3750
+ for (const row of composition.rows) {
3751
+ lines.push(t('preset.showRow', {
3752
+ module: row.moduleName,
3753
+ entry: row.entryId === null ? '' : t('preset.showRowId', { id: row.entryId }),
3754
+ state: row.enabled === true
3755
+ ? ''
3756
+ : row.enabled === false
3757
+ ? t('preset.rowDisabled')
3758
+ : t('preset.rowConditional', { condition: row.condition ?? '' }),
3759
+ }));
3760
+ }
3761
+ }
3762
+ }
3763
+ else if (typeof service.read === 'function') {
3764
+ // A host older than the inventory API still hands over the document.
3765
+ const document = await service.read(preset.id);
3766
+ lines.push(t('preset.showRawHint'));
3767
+ for (const line of document.split('\n').slice(0, 40))
3768
+ lines.push(` ${line}`);
3769
+ }
3770
+ else {
3771
+ lines.push(t('preset.unsupported', { feature: 'read' }));
3772
+ }
3773
+ this.pushRow({ kind: 'system', text: lines.join('\n') });
3774
+ this.markDirty();
3775
+ }
3776
+ /** Copy an existing preset (upstream's only creation path). */
3777
+ async presetCopy(service, from, id, name) {
3778
+ if (typeof service.copy !== 'function') {
3779
+ this.pushRow({ kind: 'error', text: t('preset.unsupported', { feature: 'copy' }) });
3780
+ this.markDirty();
3781
+ return;
3782
+ }
3783
+ const presets = await service.list();
3784
+ const plan = planCopy({
3785
+ presets,
3786
+ authorable: service.authorable === true,
3787
+ from: from ?? '',
3788
+ id: id ?? '',
3789
+ name: name === '' ? undefined : name,
3790
+ });
3791
+ if (isRefusal(plan)) {
3792
+ this.pushPresetRefusal(plan);
3793
+ return;
3794
+ }
3795
+ const copy = service.copy.bind(service);
3796
+ await this.presetWrite(async () => {
3797
+ await copy(plan.from, plan.id, plan.name);
3798
+ return t('preset.copyDone', {
3799
+ from: plan.from,
3800
+ id: plan.id,
3801
+ name: plan.name === undefined ? '' : ` · ${plan.name}`,
3802
+ directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]),
3803
+ });
3804
+ });
3805
+ }
3806
+ /** Edit the display name or description: a `preset.yml` written beside a backup. */
3807
+ async presetWriteMetadata(service, id, patch) {
3808
+ const presets = await service.list();
3809
+ const preset = presets.find(candidate => candidate.id === id);
3810
+ if (preset === undefined) {
3811
+ this.pushRow({
3812
+ kind: 'error',
3813
+ text: t('preset.unknownId', { id: id ?? '', ids: presets.map(candidate => candidate.id).join(', ') }),
3814
+ });
3815
+ this.markDirty();
3816
+ return;
3817
+ }
3818
+ if ((patch.name ?? patch.description ?? '').trim() === '') {
3819
+ this.pushRow({ kind: 'error', text: t('preset.needValue', { id: preset.id }) });
3820
+ this.markDirty();
3821
+ return;
3822
+ }
3823
+ // Merge with what the preset publishes now, so editing one field never
3824
+ // drops the other and `order` survives.
3825
+ const current = await readPresetMetadata(presetDirectory(preset));
3826
+ const plan = planMetadata({ preset, current, patch });
3827
+ if (isRefusal(plan)) {
3828
+ this.pushPresetRefusal(plan);
3829
+ return;
3830
+ }
3831
+ const path = join(plan.directory, METADATA_FILE);
3832
+ const field = patch.name === undefined ? t('preset.fieldDescription') : t('preset.fieldName');
3833
+ await this.presetWrite(async () => {
3834
+ const backup = await writePatchWithBackup(path, plan.text);
3835
+ return [
3836
+ t('preset.metadataDone', { id: plan.id, field }),
3837
+ ...(backup === undefined ? [] : [t('preset.backupLine', { path: backup })]),
3838
+ ].join('\n');
3839
+ });
3840
+ }
3841
+ /** Delete a user preset after one confirmation. */
3842
+ async presetDelete(service, id) {
3843
+ if (typeof service.remove !== 'function') {
3844
+ this.pushRow({ kind: 'error', text: t('preset.unsupported', { feature: 'delete' }) });
3845
+ this.markDirty();
3846
+ return;
3847
+ }
3848
+ const presets = await service.list();
3849
+ const preset = presets.find(candidate => candidate.id === id);
3850
+ if (preset === undefined) {
3851
+ this.pushRow({
3852
+ kind: 'error',
3853
+ text: t('preset.unknownId', { id: id ?? '', ids: presets.map(candidate => candidate.id).join(', ') }),
3854
+ });
3855
+ this.markDirty();
3856
+ return;
3857
+ }
3858
+ const plan = planDelete({
3859
+ preset,
3860
+ authorable: service.authorable === true,
3861
+ current: preset.id === this.presetId,
3862
+ });
3863
+ if (isRefusal(plan)) {
3864
+ this.pushPresetRefusal(plan);
3865
+ return;
3866
+ }
3867
+ const answer = await new Promise(resolve => {
3868
+ this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) }), resolve);
3869
+ });
3870
+ if (answer !== 'y') {
3871
+ this.pushRow({ kind: 'system', text: t('preset.deleteCancelled') });
3872
+ this.markDirty();
3873
+ return;
3874
+ }
3875
+ const remove = service.remove.bind(service);
3876
+ await this.presetWrite(async () => {
3877
+ await remove(plan.id);
3878
+ return t('preset.deleteDone', { id: plan.id });
3879
+ });
3880
+ }
3881
+ /**
3882
+ * Run one authoring call and report it.
3883
+ *
3884
+ * The service validates more than this command can (the writable root, an
3885
+ * occupied id, the trust recorded on disk), so its message is what the user
3886
+ * sees when it refuses.
3887
+ */
3888
+ async presetWrite(call) {
3889
+ try {
3890
+ this.pushRow({ kind: 'system', text: await call() });
3891
+ }
3892
+ catch (error) {
3893
+ this.pushRow({ kind: 'error', text: t('preset.failed', { error: errorChain(error) }) });
3894
+ }
3895
+ this.markDirty();
3896
+ }
3897
+ /** One refusal, in the user's language. */
3898
+ pushPresetRefusal(refusal) {
3899
+ this.pushRow({
3900
+ kind: 'error',
3901
+ text: t(`preset.refused.${refusal.error}`, {
3902
+ id: refusal.id ?? '',
3903
+ name: refusal.name ?? refusal.id ?? '',
3904
+ root: this.presetService()?.roots?.find(root => root.trust === 'user')?.path ?? '',
3905
+ }, refusal.error),
3906
+ });
3907
+ this.markDirty();
3908
+ }
3909
+ /**
3910
+ * Apply the footer's muted style around a strip that carries its own accents
3911
+ * (the link pips, the ⚠). {@link styleLine} cannot do this: it sanitises its
3912
+ * input, which strips an accent's `ESC` and leaves the `[32m` body on the grid
3913
+ * as four literal characters — the garbled footer B-1 shipped. Every inner
3914
+ * reset re-opens the muted style, so the text between two accents stays muted.
3915
+ */
3916
+ muteFooterLine(text) {
3917
+ if (text === '')
3918
+ return '';
3919
+ const code = this.mutedSgr();
3920
+ if (code === '')
3921
+ return text;
3922
+ const open = `\x1b[${code}m`;
3923
+ return `${open}${text.replaceAll('\x1b[0m', `\x1b[0m${open}`)}\x1b[0m`;
3924
+ }
3925
+ /** The muted strip separator, so the groups between the accents stay dim. */
3926
+ mutedSeparator() {
3927
+ const code = this.mutedSgr();
3928
+ return code === '' ? ' │ ' : `\x1b[${code}m │ `;
3929
+ }
3930
+ mutedSgr() {
3931
+ return this.color ? downgradeSgr('90', this.colorDepth) : '';
3932
+ }
2974
3933
  styleLine(kind, text) {
2975
3934
  const safe = sanitizeTerminalText(text);
2976
3935
  if (!this.color)
2977
3936
  return safe;
2978
- const code = kind === 'user' ? '36' :
3937
+ const requested = kind === 'user' ? '36' :
2979
3938
  kind === 'assistant' ? '37' :
2980
3939
  kind === 'reasoning' ? '2;3' :
2981
3940
  kind === 'brand' ? '1;38;2;77;107;253' :
@@ -2986,10 +3945,17 @@ export class SshTui {
2986
3945
  kind === 'diff-path' ? '1;36' :
2987
3946
  kind === 'todo-done' ? '2;32' :
2988
3947
  kind === 'todo-active' ? '1;36' :
2989
- kind === 'todo-pending' ? '90' :
2990
- kind === 'plan-dock' ? '38;5;180' :
2991
- kind === 'error' ? '31' :
2992
- '90';
3948
+ kind === 'todo-failed' ? '31' :
3949
+ kind === 'todo-skipped' ? '2;33' :
3950
+ kind === 'todo-pending' ? '90' :
3951
+ kind === 'plan-dock' ? '38;5;180' :
3952
+ kind === 'diag' ? '2;36' :
3953
+ kind === 'error' ? '31' :
3954
+ '90';
3955
+ const code = downgradeSgr(requested, this.colorDepth);
3956
+ // Nothing left to paint: the text stands on its own.
3957
+ if (code === '')
3958
+ return safe;
2993
3959
  return `\x1b[${code}m${safe}\x1b[0m`;
2994
3960
  }
2995
3961
  /**
@@ -4087,16 +5053,47 @@ export class SshTui {
4087
5053
  }
4088
5054
  return '';
4089
5055
  }
4090
- async reviewUnknownWithModel(request, command, args) {
4091
- const llm = this.ctx.get('llm');
4092
- if (llm === undefined)
4093
- return undefined;
5056
+ /**
5057
+ * The route the AI approval reviewer runs on: the subagent selection when it
5058
+ * matches the parent provider, else that provider's light model. Shared with
5059
+ * the verdict cache, whose key has to name the reviewer that produced a
5060
+ * verdict — a different model may decide differently.
5061
+ */
5062
+ reviewerRoute() {
4094
5063
  const selection = this.subagentSelection.current;
4095
5064
  const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
4096
5065
  const provider = selection.provider ?? parentProvider;
4097
5066
  const model = subagentModelMatchesProvider(provider, selection.model)
4098
5067
  ? selection.model
4099
5068
  : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
5069
+ return { provider, model };
5070
+ }
5071
+ /**
5072
+ * The cache key for one `ask`-shaped request, or `undefined` when the shape
5073
+ * must not be remembered (no command, or an opaque interpreter payload).
5074
+ */
5075
+ approvalCacheKey(request, command, args) {
5076
+ if (command === undefined)
5077
+ return undefined;
5078
+ const input = {
5079
+ toolName: request.toolName,
5080
+ command,
5081
+ args,
5082
+ reason: request.reason,
5083
+ sandboxMode: this.hostSandboxMode,
5084
+ agentId: String(request.agent.id),
5085
+ workspaceCwd: this.workspaceCwd(),
5086
+ locale: getLocale(),
5087
+ reviewer: this.reviewerRoute(),
5088
+ authorization: this.latestUserAuthorizationText(request.agent),
5089
+ };
5090
+ return cacheableShape(input) ? verdictKey(input) : undefined;
5091
+ }
5092
+ async reviewUnknownWithModel(request, command, args) {
5093
+ const llm = this.ctx.get('llm');
5094
+ if (llm === undefined)
5095
+ return undefined;
5096
+ const { provider, model } = this.reviewerRoute();
4100
5097
  // 最近模型输出/思考(≤2 段)与会话日志里最新用户消息(跳过 plugin notice)
4101
5098
  const segments = [];
4102
5099
  for (let i = this.rows.length - 1; i >= 0 && segments.length < 2; i -= 1) {
@@ -4168,7 +5165,7 @@ export class SshTui {
4168
5165
  this.markDirty();
4169
5166
  return verdict;
4170
5167
  }
4171
- recordAutoApproval(decision, risk, toolName, command, reason) {
5168
+ recordAutoApproval(decision, risk, toolName, command, reason, fromCache) {
4172
5169
  if (decision === 'allow')
4173
5170
  this.autoAllowedCount += 1;
4174
5171
  else
@@ -4179,12 +5176,20 @@ export class SshTui {
4179
5176
  const clipped = Array.from(subject).length > 160
4180
5177
  ? `${Array.from(subject).slice(0, 160).join('')}…`
4181
5178
  : subject;
4182
- const rowText = t('approval.decisionRow', {
4183
- verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
4184
- command: clipped,
4185
- risk,
4186
- reason,
4187
- });
5179
+ const rowText = fromCache === undefined
5180
+ ? t('approval.decisionRow', {
5181
+ verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
5182
+ command: clipped,
5183
+ risk,
5184
+ reason,
5185
+ })
5186
+ : t('approval.cacheHitRow', {
5187
+ age: formatShortDuration(fromCache.ageMs),
5188
+ verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
5189
+ command: clipped,
5190
+ risk,
5191
+ reason,
5192
+ });
4188
5193
  this.pushRow({ kind: 'system', text: rowText });
4189
5194
  this.markDirty();
4190
5195
  if (decision === 'deny')
@@ -4246,10 +5251,22 @@ export class SshTui {
4246
5251
  return 'rejected';
4247
5252
  }
4248
5253
  // Unknown shape: the rule table cannot judge it — hand it to the
4249
- // subagent-configured model with compact context (AI review). Without
4250
- // a display there is nobody to fall back on, so unreviewable asks
4251
- // reject and the turn completes instead of stalling.
5254
+ // subagent-configured model with compact context (AI review). A verdict
5255
+ // this TUI already produced for the exact same request (tool, command,
5256
+ // arguments, workspace, reviewer, and the user message that authorized
5257
+ // it) is reused inside its TTL instead of paying for the review again.
5258
+ const cacheKey = this.approvalCacheKey(request, command, row?.args);
5259
+ const cached = cacheKey === undefined ? undefined : this.approvalCache.lookup(cacheKey);
5260
+ if (cached !== undefined) {
5261
+ this.cacheHitCount += 1;
5262
+ this.recordAutoApproval(cached.verdict.approved ? 'allow' : 'deny', cached.verdict.risk, request.toolName, command, cached.verdict.reason === ''
5263
+ ? cached.verdict.approved ? t('approval.reviewApproved') : t('approval.reviewRejected')
5264
+ : cached.verdict.reason, { fromCache: true, ageMs: cached.ageMs });
5265
+ return cached.verdict.approved ? 'allowed-once' : 'rejected';
5266
+ }
4252
5267
  const reviewed = await this.reviewUnknownWithModel(request, command, row?.args);
5268
+ if (reviewed !== undefined && cacheKey !== undefined)
5269
+ this.approvalCache.store(cacheKey, reviewed);
4253
5270
  if (reviewed?.approved === true) {
4254
5271
  this.recordAutoApproval('allow', reviewed.risk, request.toolName, command, reviewed.reason === '' ? t('approval.reviewApproved') : reviewed.reason);
4255
5272
  return 'allowed-once';
@@ -4259,6 +5276,7 @@ export class SshTui {
4259
5276
  return 'rejected';
4260
5277
  }
4261
5278
  if (!this.hasLiveDisplay()) {
5279
+ this.detachedDeniedCount += 1;
4262
5280
  this.recordAutoApproval('deny', 'medium', request.toolName, command, t('approval.ruleDetached'));
4263
5281
  return 'rejected';
4264
5282
  }
@@ -4297,8 +5315,17 @@ export class SshTui {
4297
5315
  });
4298
5316
  };
4299
5317
  handleUserQuestions = async (request) => {
4300
- if (!this.hasLiveDisplay())
4301
- await this.waitForLiveDisplay(request.signal);
5318
+ if (!this.hasLiveDisplay()) {
5319
+ // Counted here, not read off the rows: the card is built only after the
5320
+ // wait resolves, so at reattach time a queued question has no row yet.
5321
+ this.queuedQuestions += 1;
5322
+ try {
5323
+ await this.waitForLiveDisplay(request.signal);
5324
+ }
5325
+ finally {
5326
+ this.queuedQuestions -= 1;
5327
+ }
5328
+ }
4302
5329
  const answers = [];
4303
5330
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
4304
5331
  ? undefined
@@ -4436,14 +5463,23 @@ export class SshTui {
4436
5463
  this.removeQueuedDialog(dialog);
4437
5464
  dialog.resolve('cancel');
4438
5465
  }
4439
- openQuestion(question, index, total, resolve, reject, preselected) {
5466
+ openQuestion(question, index, total, resolve, reject, preselected, matchKeys) {
5467
+ // A list opens with its first option already chosen: Enter then answers that
5468
+ // default for a single- and a multi-select question alike, instead of
5469
+ // coming back with an empty selection. Esc stays the explicit cancel, and a
5470
+ // question with no options still answers with the typed text.
5471
+ const optionCount = question.options?.length ?? 0;
5472
+ const initial = preselected !== undefined && preselected >= 0 && preselected < optionCount
5473
+ ? preselected
5474
+ : optionCount > 0 ? 0 : -1;
4440
5475
  const dialog = {
4441
5476
  kind: 'questions',
4442
5477
  question,
4443
5478
  index,
4444
5479
  total,
4445
- selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
4446
- cursor: preselected !== undefined && preselected >= 0 ? preselected : 0,
5480
+ selected: new Set(initial >= 0 ? [initial] : []),
5481
+ cursor: initial >= 0 ? initial : 0,
5482
+ ...(matchKeys === undefined ? {} : { matchKeys }),
4447
5483
  resolve: (selection) => {
4448
5484
  this.settleQuestion(dialog, () => resolve(selection));
4449
5485
  },
@@ -4455,9 +5491,9 @@ export class SshTui {
4455
5491
  return dialog;
4456
5492
  }
4457
5493
  /** Open one question dialog and await its answer (cancellation rejects). */
4458
- askQuestion(question, index = 0, total = 1, preselected) {
5494
+ askQuestion(question, index = 0, total = 1, preselected, matchKeys) {
4459
5495
  return new Promise((resolve, reject) => {
4460
- this.openQuestion(question, index, total, resolve, reject, preselected);
5496
+ this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys);
4461
5497
  });
4462
5498
  }
4463
5499
  /** The stored llm-pi-ai profile for one provider route, when settings provide one. */
@@ -5374,48 +6410,53 @@ export class SshTui {
5374
6410
  this.markDirty();
5375
6411
  return;
5376
6412
  }
5377
- // Shipped presets resolve through the TUI dictionary; a user-authored one
5378
- // keeps the name its own preset.yml published.
5379
- const labels = presets.map(preset => presetLabel(preset.id, preset.name, preset.trust));
5380
- const matchesDirect = (preset, label) => preset.id.toLowerCase() === direct
5381
- || (preset.name ?? '').trim().toLowerCase() === direct
5382
- || label.toLowerCase() === direct;
6413
+ // Shipped presets first, then locally authored ones, each line naming its
6414
+ // group and position: the dialog is a flat list, so the grouping lives in
6415
+ // the order and the text.
6416
+ const options = flattenGroups(groupPresets(presets, this.presetId));
6417
+ const matchesDirect = (option) => option.id.toLowerCase() === direct || option.label.toLowerCase() === direct
6418
+ // A published name still selects its preset even when the label resolves
6419
+ // through the dictionary instead.
6420
+ || optionMatches(option, direct);
5383
6421
  let index = direct === ''
5384
6422
  ? -1
5385
- : presets.findIndex((preset, at) => matchesDirect(preset, labels[at] ?? preset.id));
6423
+ : options.findIndex(option => matchesDirect(option));
5386
6424
  if (index < 0 && direct !== '') {
5387
6425
  this.pushRow({
5388
6426
  kind: 'error',
5389
- text: t('mode.unknown', { id: arg.trim(), available: presets.map(preset => preset.id).join(', ') }),
6427
+ text: t('mode.unknown', { id: arg.trim(), available: options.map(option => option.id).join(', ') }),
5390
6428
  });
5391
6429
  this.markDirty();
5392
6430
  return;
5393
6431
  }
5394
6432
  if (index < 0 && direct === '') {
6433
+ // Like every other picker, the list opens on what is in effect now.
6434
+ const currentIndex = options.findIndex(option => option.id === this.presetId);
5395
6435
  const answer = await this.askQuestion({
5396
6436
  id: 'mode-pick',
5397
6437
  question: t('mode.pick'),
5398
- options: presets.map((preset, at) => ({
5399
- label: labels[at] ?? preset.id,
5400
- description: `${preset.id === this.presetId ? t('mode.currentPrefix') : ''}${preset.broken === undefined ? preset.description ?? '' : t('mode.brokenSuffix', { reason: preset.broken })}`.trim(),
5401
- })),
5402
- });
5403
- index = labels.indexOf(answer.selected[0] ?? '');
6438
+ options: options.map(option => ({ label: option.label, description: option.description })),
6439
+ }, 0, 1, currentIndex >= 0 ? currentIndex : undefined, options.map(option => option.id));
6440
+ const picked = answer.selected[0] ?? '';
6441
+ index = options.findIndex(option => option.label === picked);
5404
6442
  }
5405
6443
  if (index < 0)
5406
6444
  return;
5407
- const selected = presets[index];
6445
+ const option = options[index];
6446
+ if (option === undefined)
6447
+ return;
6448
+ const selected = presets.find(preset => preset.id === option.id);
5408
6449
  if (selected === undefined)
5409
6450
  return;
5410
- if (selected.broken !== undefined) {
6451
+ if (option.broken !== undefined) {
5411
6452
  this.pushRow({
5412
6453
  kind: 'error',
5413
- text: t('mode.broken', { name: labels[index] ?? selected.id, reason: selected.broken }),
6454
+ text: t('mode.broken', { name: option.label, reason: option.broken }),
5414
6455
  });
5415
6456
  this.markDirty();
5416
6457
  return;
5417
6458
  }
5418
- const selectedName = labels[index] ?? selected.id;
6459
+ const selectedName = option.label;
5419
6460
  const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
5420
6461
  if (!hasWork) {
5421
6462
  await agentPresets.recompose(this.agent.ctx, selected.id);
@@ -5439,11 +6480,16 @@ export class SshTui {
5439
6480
  * patch tree once at boot — so the report says so instead of pretending the
5440
6481
  * running session gained a roster.
5441
6482
  */
6483
+ /** Re-read the roster fact this process was composed with (cheap, no IO). */
6484
+ refreshRosterHealth() {
6485
+ this.rosterMissing = this.ctx.get('agentPresets') === undefined;
6486
+ }
5442
6487
  async repairRoster() {
5443
6488
  const profile = profileFromArgv();
5444
6489
  const patch = rosterPatchPath(resolveDshHome(), profile);
5445
6490
  try {
5446
6491
  const result = await ensureRosterRows(resolveDshHome(), profile);
6492
+ this.refreshRosterHealth();
5447
6493
  this.pushRow({
5448
6494
  kind: 'system',
5449
6495
  text: result === 'present' ? t('mode.fixPresent', { patch }) : t('mode.fixWritten', { patch }),
@@ -5776,6 +6822,19 @@ export class SshTui {
5776
6822
  this.processPasteChunk(combined);
5777
6823
  return;
5778
6824
  }
6825
+ // A key the user moved, and a key their move displaced. Consulted before any
6826
+ // routing so a plain control byte reaches it as readily as a CSI sequence;
6827
+ // everything else keeps the handling below, which is what makes the defaults
6828
+ // unchanged by construction.
6829
+ if (this.dialog === undefined && combined !== '') {
6830
+ const moved = this.keymap.sequences.get(combined);
6831
+ if (moved !== undefined && this.keymap.overridden.has(moved)) {
6832
+ if (this.runKeyAction(moved))
6833
+ return;
6834
+ }
6835
+ if (this.keymap.suppressed.has(combined))
6836
+ return;
6837
+ }
5779
6838
  const escape = /^\x1b\[([A-D])$/u;
5780
6839
  const match = combined.match(escape);
5781
6840
  if (match !== null) {
@@ -5833,6 +6892,7 @@ export class SshTui {
5833
6892
  const sgrMouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u.exec(combined);
5834
6893
  if (sgrMouse !== null) {
5835
6894
  const button = Number(sgrMouse[1]);
6895
+ const x = Number(sgrMouse[2]);
5836
6896
  const y = Number(sgrMouse[3]);
5837
6897
  if (sgrMouse[4] === 'M') {
5838
6898
  if (button === 64) {
@@ -5843,12 +6903,20 @@ export class SshTui {
5843
6903
  this.scrollInspectOrTranscript(-3);
5844
6904
  return;
5845
6905
  }
6906
+ // 32 is "motion with the left button held": a drag over a reply.
6907
+ if (button === 32) {
6908
+ this.extendMouseSelection(y, x);
6909
+ return;
6910
+ }
5846
6911
  if (button === 0) {
5847
- const x = Number(sgrMouse[2]);
5848
- this.handleMouseClick(y, x);
6912
+ this.beginMouseSelection(y, x);
5849
6913
  return;
5850
6914
  }
6915
+ return;
5851
6916
  }
6917
+ // A release ends either a drag (copy what it covered) or a plain click.
6918
+ if (button === 0)
6919
+ this.endMouseSelection(y, x);
5852
6920
  return;
5853
6921
  }
5854
6922
  if (combined === '\x1b[5~') {
@@ -6135,6 +7203,32 @@ export class SshTui {
6135
7203
  this.markDirty();
6136
7204
  }
6137
7205
  }
7206
+ /**
7207
+ * Run one rebindable action.
7208
+ * @param action - the action the pressed key was moved to.
7209
+ * @returns true when the key was consumed.
7210
+ */
7211
+ runKeyAction(action) {
7212
+ switch (action) {
7213
+ case 'pageUp':
7214
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor(this.screenRows() / 2)));
7215
+ return true;
7216
+ case 'pageDown':
7217
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor(this.screenRows() / 2)));
7218
+ return true;
7219
+ case 'toggleCard':
7220
+ this.toggleCollapsible();
7221
+ return true;
7222
+ case 'copy':
7223
+ this.copyFocusedCard();
7224
+ return true;
7225
+ case 'cancel':
7226
+ this.handleEscape();
7227
+ return true;
7228
+ default:
7229
+ return false;
7230
+ }
7231
+ }
6138
7232
  moveQuestionCursor(delta) {
6139
7233
  const dialog = this.dialog;
6140
7234
  if (dialog === undefined || dialog.kind !== 'questions')
@@ -6163,6 +7257,32 @@ export class SshTui {
6163
7257
  this.closeConfirm(answer);
6164
7258
  return;
6165
7259
  }
7260
+ // Filter mode owns the printable keys: the list's hotkeys are letters and
7261
+ // digits, so the two cannot both be live at once.
7262
+ if (dialog.filtering === true) {
7263
+ if (text === '\r' || text === '\n') {
7264
+ applyQuestionFilter(dialog);
7265
+ this.markDirty();
7266
+ return;
7267
+ }
7268
+ if (text === '\x7f' || text === '\b') {
7269
+ backspaceQuestionFilter(dialog);
7270
+ this.markDirty();
7271
+ return;
7272
+ }
7273
+ if (text.length === 1 && text >= ' ') {
7274
+ typeQuestionFilter(dialog, text);
7275
+ this.markDirty();
7276
+ return;
7277
+ }
7278
+ return;
7279
+ }
7280
+ if (text === '/' && (dialog.question.options?.length ?? 0) > 0) {
7281
+ dialog.filtering = true;
7282
+ dialog.filter = '';
7283
+ this.markDirty();
7284
+ return;
7285
+ }
6166
7286
  if (selectQuestionOptionByKey(dialog, text))
6167
7287
  this.markDirty();
6168
7288
  if (text === '\r' || text === '\n') {
@@ -6771,6 +7891,15 @@ export class SshTui {
6771
7891
  }
6772
7892
  }
6773
7893
  handleEscape() {
7894
+ if (this.dialog?.kind === 'questions'
7895
+ && (this.dialog.filtering === true || (this.dialog.filter ?? '') !== '')) {
7896
+ // Esc means "show me everything again" while a filter is up; cancelling
7897
+ // the whole question on the same key would throw away the answer the user
7898
+ // is still composing.
7899
+ clearQuestionFilter(this.dialog);
7900
+ this.markDirty();
7901
+ return;
7902
+ }
6774
7903
  if (this.dialog !== undefined) {
6775
7904
  if (this.dialog.kind === 'inspect') {
6776
7905
  this.closeInspect();
@@ -6808,9 +7937,147 @@ export class SshTui {
6808
7937
  }
6809
7938
  }
6810
7939
  /** Toggle the collapsible row under a click, or copy an OSC-8 link. */
7940
+ /**
7941
+ * The anchor's line index in the *current* frame.
7942
+ *
7943
+ * A live session repaints while the button is down, and the transcript is
7944
+ * pinned to the bottom: a row arriving below shifts everything up by one. The
7945
+ * stored index would then point at whatever moved into that screen row, so
7946
+ * the anchor is re-found by the row it grabbed (and by its text when the row
7947
+ * carries no identity). When the row has scrolled out of view entirely, the
7948
+ * old index is the best that can be done.
7949
+ */
7950
+ resolveAnchorLine() {
7951
+ const anchor = this.mouseAnchor;
7952
+ if (anchor === undefined)
7953
+ return undefined;
7954
+ const candidates = [];
7955
+ for (let index = 0; index < this.selectableLines.length; index += 1) {
7956
+ const line = this.selectableLines[index];
7957
+ if (line === undefined || line.copyable !== true)
7958
+ continue;
7959
+ if (this.mouseAnchorRef !== undefined) {
7960
+ if (line.ref === this.mouseAnchorRef)
7961
+ candidates.push(index);
7962
+ }
7963
+ else if (stripAnsi(line.raw) === this.mouseAnchorText) {
7964
+ candidates.push(index);
7965
+ }
7966
+ }
7967
+ if (candidates.length === 0)
7968
+ return anchor.line;
7969
+ return candidates.reduce((best, index) => (Math.abs(index - anchor.line) < Math.abs(best - anchor.line) ? index : best), candidates[0] ?? anchor.line);
7970
+ }
7971
+ /** The painted lines as plain text, for the selection rules. */
7972
+ plainSelectableLines() {
7973
+ return this.selectableLines.map(line => ({ text: stripAnsi(line.raw), copyable: line.copyable }));
7974
+ }
7975
+ /** Screen row from a mouse report to an index into `selectableLines`. */
7976
+ selectableLineAt(screenY) {
7977
+ const index = screenY - this.transcriptTopScreenY;
7978
+ if (index < 0 || index >= this.selectableLines.length)
7979
+ return undefined;
7980
+ return index;
7981
+ }
7982
+ /**
7983
+ * Left button pressed. The click itself waits for the release: this may turn
7984
+ * into a drag, and a press that never moves must still open a link or toggle
7985
+ * a card exactly as before.
7986
+ */
7987
+ beginMouseSelection(y, x) {
7988
+ this.pendingMouseClick = undefined;
7989
+ this.mouseAnchor = undefined;
7990
+ if (this.dialog !== undefined) {
7991
+ this.pendingMouseClick = { y, x };
7992
+ return;
7993
+ }
7994
+ const line = this.selectableLineAt(y);
7995
+ if (line === undefined || this.selectableLines[line]?.copyable !== true) {
7996
+ this.pendingMouseClick = { y, x };
7997
+ return;
7998
+ }
7999
+ this.mouseAnchor = { line, column: Math.max(0, x - 1) };
8000
+ const grabbed = this.selectableLines[line];
8001
+ this.mouseAnchorRef = grabbed?.ref;
8002
+ this.mouseAnchorText = grabbed === undefined ? '' : stripAnsi(grabbed.raw);
8003
+ this.mouseSelection = undefined;
8004
+ }
8005
+ /** The button is held and the pointer moved: extend the drag and repaint it. */
8006
+ extendMouseSelection(y, x) {
8007
+ const resolved = this.resolveAnchorLine();
8008
+ if (resolved === undefined || this.mouseAnchor === undefined)
8009
+ return;
8010
+ this.mouseAnchor = { line: resolved, column: this.mouseAnchor.column };
8011
+ const anchor = this.mouseAnchor;
8012
+ const line = this.selectableLineAt(y) ?? (y < this.transcriptTopScreenY ? 0 : this.selectableLines.length - 1);
8013
+ const clamped = clampSelection(this.plainSelectableLines(), anchor, { line, column: Math.max(0, x - 1) });
8014
+ if (clamped === undefined)
8015
+ return;
8016
+ const same = this.mouseSelection !== undefined
8017
+ && this.mouseSelection.from.line === clamped.from.line
8018
+ && this.mouseSelection.from.column === clamped.from.column
8019
+ && this.mouseSelection.to.line === clamped.to.line
8020
+ && this.mouseSelection.to.column === clamped.to.column;
8021
+ this.mouseSelection = clamped;
8022
+ if (!same)
8023
+ this.markDirty();
8024
+ }
8025
+ /** Release: copy the dragged text, or perform the click that never became one. */
8026
+ endMouseSelection(y, x) {
8027
+ const resolved = this.resolveAnchorLine();
8028
+ const anchor = resolved === undefined || this.mouseAnchor === undefined
8029
+ ? undefined
8030
+ : { line: resolved, column: this.mouseAnchor.column };
8031
+ const selection = this.mouseSelection;
8032
+ const click = this.pendingMouseClick;
8033
+ this.mouseAnchor = undefined;
8034
+ this.mouseAnchorRef = undefined;
8035
+ this.mouseAnchorText = '';
8036
+ this.mouseSelection = undefined;
8037
+ this.pendingMouseClick = undefined;
8038
+ if (anchor === undefined) {
8039
+ if (click !== undefined)
8040
+ this.handleMouseClick(click.y, click.x);
8041
+ return;
8042
+ }
8043
+ const text = selection === undefined ? '' : selectionText(this.plainSelectableLines(), selection);
8044
+ if (text.trim() === '') {
8045
+ // A press that did not really drag is a click, not an empty copy.
8046
+ this.handleMouseClick(y, x);
8047
+ this.markDirty();
8048
+ return;
8049
+ }
8050
+ const lines = selection === undefined ? 1 : selection.to.line - selection.from.line + 1;
8051
+ this.copyPlainText(text, t('copy.selection', { chars: text.length, lines }));
8052
+ this.markDirty();
8053
+ }
8054
+ /** Paint the drag in reverse video; the affected lines drop their colors. */
8055
+ highlightSelection(visible) {
8056
+ const selection = this.mouseSelection;
8057
+ if (selection === undefined)
8058
+ return [...visible];
8059
+ const plain = this.plainSelectableLines();
8060
+ const spans = selectionSpans(plain, selection);
8061
+ if (spans.length === 0)
8062
+ return [...visible];
8063
+ const lines = [...visible];
8064
+ for (const span of spans) {
8065
+ const text = plain[span.line]?.text ?? '';
8066
+ const startOffset = offsetAtColumn(text, span.start);
8067
+ const endOffset = offsetAfterColumn(text, span.end);
8068
+ lines[span.line] = `${text.slice(0, startOffset)}\x1b[7m${text.slice(startOffset, endOffset)}\x1b[27m${text.slice(endOffset)}`;
8069
+ }
8070
+ return lines;
8071
+ }
6811
8072
  handleMouseClick(y, x = 1) {
6812
8073
  if (this.dialog !== undefined)
6813
8074
  return;
8075
+ // The health chip is a standing warning, so clicking it opens the report
8076
+ // that explains it — the row is the strip, and the chip leads it.
8077
+ if (this.healthChipRow !== undefined && y === this.healthChipRow) {
8078
+ this.runCommand('/doctor');
8079
+ return;
8080
+ }
6814
8081
  const hits = this.paintedLinkHitsByRow.get(y);
6815
8082
  const href = hits === undefined ? undefined : hrefAtColumn(hits, Math.max(0, x - 1));
6816
8083
  if (href !== undefined && href.trim() !== '') {
@@ -6837,18 +8104,38 @@ export class SshTui {
6837
8104
  this.pushRow({ kind: 'system', text: notice });
6838
8105
  this.markDirty();
6839
8106
  }
6840
- copyFocusedCard() {
8107
+ copyFocusedCard(arg = '') {
6841
8108
  if (this.dialog !== undefined)
6842
8109
  return false;
6843
- const picked = copyTextFromTranscript(this.rows, this.focusedRow);
6844
- if (picked.text.trim() === '') {
6845
- this.pushRow({ kind: 'system', text: t('copy.empty') });
6846
- this.markDirty();
6847
- return false;
8110
+ // `error` copies the newest failure or diagnostic row: a long path or a
8111
+ // command in one of those must reach the clipboard whole, which is exactly
8112
+ // what copying the row's own text does (the wrapped screen lines a drag
8113
+ // would join are not what the user wants to paste into a report).
8114
+ if (arg.trim().toLowerCase() === 'error') {
8115
+ const failed = this.rows.findLast(row => row.kind === 'error' || row.kind === 'diag');
8116
+ const text = copyTextFromRow(failed);
8117
+ if (text.trim() === '') {
8118
+ this.pushRow({ kind: 'system', text: t('copy.noError') });
8119
+ this.markDirty();
8120
+ return false;
8121
+ }
8122
+ this.copyPlainText(text, t('copy.ok', { chars: text.length, source: t('copy.sourceError') }));
8123
+ return true;
6848
8124
  }
6849
- const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
6850
- this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
6851
- return true;
8125
+ if (arg.trim() === '') {
8126
+ const picked = copyTextFromTranscript(this.rows, this.focusedRow);
8127
+ if (picked.text.trim() === '') {
8128
+ this.pushRow({ kind: 'system', text: t('copy.empty') });
8129
+ this.markDirty();
8130
+ return false;
8131
+ }
8132
+ const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
8133
+ this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
8134
+ return true;
8135
+ }
8136
+ this.pushRow({ kind: 'system', text: t('copy.usage') });
8137
+ this.markDirty();
8138
+ return false;
6852
8139
  }
6853
8140
  scrollInspectOrTranscript(delta) {
6854
8141
  if (this.dialog?.kind === 'inspect') {
@@ -7075,7 +8362,7 @@ export class SshTui {
7075
8362
  });
7076
8363
  break;
7077
8364
  case 'copy':
7078
- this.copyFocusedCard();
8365
+ this.copyFocusedCard(arg);
7079
8366
  break;
7080
8367
  case 'find':
7081
8368
  this.runFindCommand(arg);
@@ -7134,6 +8421,18 @@ export class SshTui {
7134
8421
  this.markDirty();
7135
8422
  });
7136
8423
  break;
8424
+ case 'doctor':
8425
+ void this.runDoctorCommand(arg, /--fix|(^|\s)fix(\s|$)/u.test(arg)).catch((error) => {
8426
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
8427
+ this.markDirty();
8428
+ });
8429
+ break;
8430
+ case 'fix':
8431
+ void this.runFixCommand(arg).catch((error) => {
8432
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
8433
+ this.markDirty();
8434
+ });
8435
+ break;
7137
8436
  case 'usage':
7138
8437
  case 'balance':
7139
8438
  case 'quota':
@@ -7200,12 +8499,35 @@ export class SshTui {
7200
8499
  allowed: this.autoAllowedCount,
7201
8500
  denied: this.autoDeniedCount,
7202
8501
  reviewed: this.aiReviewCount,
8502
+ cacheHits: this.cacheHitCount,
8503
+ cached: this.approvalCache.size,
7203
8504
  })
7204
8505
  : t('approval.statusOff'),
7205
8506
  });
7206
8507
  this.markDirty();
7207
8508
  break;
7208
8509
  }
8510
+ // The cache is the one piece of auto-approval state the user can
8511
+ // inspect and drop on demand: `/approval cache` reports it,
8512
+ // `/approval cache clear` forgets every remembered verdict.
8513
+ if (requested === 'cache' || requested === 'cache clear' || requested === 'cache status') {
8514
+ if (requested === 'cache clear') {
8515
+ const dropped = this.approvalCache.clear();
8516
+ this.pushRow({ kind: 'system', text: t('approval.cacheCleared', { count: dropped }) });
8517
+ }
8518
+ else {
8519
+ this.pushRow({
8520
+ kind: 'system',
8521
+ text: t('approval.cacheStatus', {
8522
+ cached: this.approvalCache.size,
8523
+ cacheHits: this.cacheHitCount,
8524
+ minutes: this.approvalCache.ttlMinutes,
8525
+ }),
8526
+ });
8527
+ }
8528
+ this.markDirty();
8529
+ break;
8530
+ }
7209
8531
  const next = requested === 'toggle'
7210
8532
  ? this.autoApprovalMode === 'auto' ? 'off' : 'auto'
7211
8533
  : parseAutoApprovalMode(requested);
@@ -7215,6 +8537,9 @@ export class SshTui {
7215
8537
  break;
7216
8538
  }
7217
8539
  this.autoApprovalMode = next;
8540
+ // A remembered verdict belongs to the mode that produced it; leaving
8541
+ // auto mode (or re-entering it) starts from an empty cache.
8542
+ this.approvalCache.clear();
7218
8543
  void this.mergeUiSettings({ autoApproval: next }).catch((error) => {
7219
8544
  this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'approval', error: errorChain(error) }) });
7220
8545
  this.markDirty();
@@ -7228,6 +8553,12 @@ export class SshTui {
7228
8553
  this.markDirty();
7229
8554
  break;
7230
8555
  }
8556
+ case 'preset':
8557
+ void this.runPresetCommand(arg).catch((error) => {
8558
+ this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
8559
+ this.markDirty();
8560
+ });
8561
+ break;
7231
8562
  case 'setup':
7232
8563
  void this.runOnboarding();
7233
8564
  break;