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