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