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.
- package/README.en.md +14 -0
- package/README.md +14 -0
- package/docs/remote-ops.md +133 -0
- package/docs/screenshots/compare.png +0 -0
- package/docs/screenshots/headless.png +0 -0
- package/docs/screenshots/workspace.png +0 -0
- package/lib/approval-cache.js +158 -0
- package/lib/approval-cache.js.map +1 -0
- package/lib/attach.js +66 -7
- package/lib/attach.js.map +1 -1
- package/lib/auto-approval.js +13 -0
- package/lib/auto-approval.js.map +1 -1
- package/lib/color-depth.js +192 -0
- package/lib/color-depth.js.map +1 -0
- package/lib/commands.js +5 -0
- package/lib/commands.js.map +1 -1
- package/lib/copy-text.js +1 -0
- package/lib/copy-text.js.map +1 -1
- package/lib/dialogs.js +81 -8
- package/lib/dialogs.js.map +1 -1
- package/lib/display-sock.js +24 -8
- package/lib/display-sock.js.map +1 -1
- package/lib/doctor.js +380 -0
- package/lib/doctor.js.map +1 -0
- package/lib/footer.js +76 -6
- package/lib/footer.js.map +1 -1
- package/lib/i18n/en.js +136 -3
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +137 -4
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +8 -1
- package/lib/index.js.map +1 -1
- package/lib/keymap.js +172 -0
- package/lib/keymap.js.map +1 -0
- package/lib/line-diff.js +224 -0
- package/lib/line-diff.js.map +1 -0
- package/lib/line-mode.js +86 -0
- package/lib/line-mode.js.map +1 -0
- package/lib/paint.js +158 -26
- package/lib/paint.js.map +1 -1
- package/lib/plan.js +51 -8
- package/lib/plan.js.map +1 -1
- package/lib/preset-authoring.js +110 -0
- package/lib/preset-authoring.js.map +1 -0
- package/lib/preset-picker.js +105 -0
- package/lib/preset-picker.js.map +1 -0
- package/lib/preset-rows.js +288 -34
- package/lib/preset-rows.js.map +1 -1
- package/lib/selection.js +154 -0
- package/lib/selection.js.map +1 -0
- package/lib/term-text.js +106 -1
- package/lib/term-text.js.map +1 -1
- package/lib/tool-present.js +157 -21
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +1428 -97
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-cache.d.ts +125 -0
- package/lib/types/attach.d.ts +29 -0
- package/lib/types/auto-approval.d.ts +11 -0
- package/lib/types/color-depth.d.ts +50 -0
- package/lib/types/commands.d.ts +9 -0
- package/lib/types/dialogs.d.ts +36 -5
- package/lib/types/display-sock.d.ts +6 -0
- package/lib/types/doctor.d.ts +118 -0
- package/lib/types/footer.d.ts +35 -0
- package/lib/types/keymap.d.ts +68 -0
- package/lib/types/line-diff.d.ts +107 -0
- package/lib/types/line-mode.d.ts +36 -0
- package/lib/types/paint.d.ts +89 -4
- package/lib/types/plan.d.ts +7 -0
- package/lib/types/preset-authoring.d.ts +135 -0
- package/lib/types/preset-picker.d.ts +64 -0
- package/lib/types/preset-rows.d.ts +113 -9
- package/lib/types/selection.d.ts +81 -0
- package/lib/types/term-text.d.ts +29 -0
- package/lib/types/tool-present.d.ts +53 -3
- package/lib/types/transcript-types.d.ts +12 -2
- package/lib/types/tui.d.ts +231 -2
- 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 {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
593
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2589
|
-
|
|
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
|
|
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 <
|
|
2604
|
-
addDialog(` ${t('picker.moreBelow', { count:
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
2990
|
-
kind === '
|
|
2991
|
-
kind === '
|
|
2992
|
-
'
|
|
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
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
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 =
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
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).
|
|
4250
|
-
//
|
|
4251
|
-
//
|
|
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
|
-
|
|
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(
|
|
4446
|
-
cursor:
|
|
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
|
|
5378
|
-
//
|
|
5379
|
-
|
|
5380
|
-
const
|
|
5381
|
-
|
|
5382
|
-
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
5399
|
-
|
|
5400
|
-
|
|
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
|
|
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 (
|
|
6451
|
+
if (option.broken !== undefined) {
|
|
5411
6452
|
this.pushRow({
|
|
5412
6453
|
kind: 'error',
|
|
5413
|
-
text: t('mode.broken', { name:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
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
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
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;
|