dsh-ssh-tui 0.8.0 → 0.8.1
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 +34 -0
- package/README.md +435 -577
- package/docs/display-mode.md +122 -0
- package/docs/remote-ops.md +51 -0
- package/docs/terminals.md +53 -0
- package/lib/attach.js +4 -4
- package/lib/attach.js.map +1 -1
- package/lib/auth-failure.js +36 -0
- package/lib/auth-failure.js.map +1 -1
- package/lib/commands.js +2 -0
- package/lib/commands.js.map +1 -1
- package/lib/dialogs.js +43 -0
- package/lib/dialogs.js.map +1 -1
- package/lib/display-mode.js +147 -0
- package/lib/display-mode.js.map +1 -0
- package/lib/display-sock.js +361 -21
- package/lib/display-sock.js.map +1 -1
- package/lib/footer.js +6 -9
- package/lib/footer.js.map +1 -1
- package/lib/glyph-measure.js +92 -0
- package/lib/glyph-measure.js.map +1 -0
- package/lib/i18n/en.js +18 -2
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +18 -2
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +73 -5
- package/lib/index.js.map +1 -1
- package/lib/paint.js +22 -9
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +14 -13
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +11 -11
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +96 -0
- package/lib/platform.js.map +1 -1
- package/lib/session-blank.js +81 -0
- package/lib/session-blank.js.map +1 -0
- package/lib/session-list.js +102 -81
- package/lib/session-list.js.map +1 -1
- package/lib/startup.js +7 -0
- package/lib/startup.js.map +1 -1
- package/lib/subagent-model.js +8 -7
- package/lib/subagent-model.js.map +1 -1
- package/lib/term-text.js +296 -28
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-input.js +132 -10
- package/lib/terminal-input.js.map +1 -1
- package/lib/theme.js +318 -0
- package/lib/theme.js.map +1 -0
- package/lib/tool-present.js +11 -9
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +420 -38
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +6 -2
- package/lib/types/auth-failure.d.ts +30 -0
- package/lib/types/commands.d.ts +6 -0
- package/lib/types/dialogs.d.ts +36 -0
- package/lib/types/display-mode.d.ts +99 -0
- package/lib/types/display-sock.d.ts +75 -0
- package/lib/types/footer.d.ts +1 -1
- package/lib/types/glyph-measure.d.ts +41 -0
- package/lib/types/index.d.ts +13 -0
- package/lib/types/plan.d.ts +5 -2
- package/lib/types/platform.d.ts +83 -0
- package/lib/types/session-blank.d.ts +51 -0
- package/lib/types/session-list.d.ts +34 -0
- package/lib/types/startup.d.ts +6 -0
- package/lib/types/subagent-model.d.ts +7 -6
- package/lib/types/term-text.d.ts +55 -23
- package/lib/types/terminal-input.d.ts +37 -0
- package/lib/types/theme.d.ts +109 -0
- package/lib/types/tool-present.d.ts +2 -2
- package/lib/types/tui.d.ts +98 -1
- package/package.json +68 -66
package/lib/tui.js
CHANGED
|
@@ -32,23 +32,26 @@ import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from '.
|
|
|
32
32
|
import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
|
|
33
33
|
import { catalogContextWindow, catalogWindowIndex, suggestedRouteContextWindow, } from './context-window.js';
|
|
34
34
|
import { formatFooterCwd } from './session-list.js';
|
|
35
|
+
import { pruneBlankSessions } from './session-list.js';
|
|
35
36
|
import { collectDiag, formatDiag } from './diag.js';
|
|
36
37
|
import { ApprovalVerdictCache, cacheableShape, verdictKey } from './approval-cache.js';
|
|
37
38
|
import { collectDoctor, doctorChecks, formatDoctorReport, rowsToRepair } from './doctor.js';
|
|
38
39
|
import { colorDepth, downgradeSgr } from './color-depth.js';
|
|
39
40
|
import { appendRow, lineModeEnabled, lineModeLines } from './line-mode.js';
|
|
40
41
|
import { keymapReport, resolveKeymap } from './keymap.js';
|
|
41
|
-
import { classifyAuthFailure } from './auth-failure.js';
|
|
42
|
+
import { classifyAuthFailure, isReasoningReplayFailure } from './auth-failure.js';
|
|
43
|
+
import { resolveTheme, setActiveTheme, themeByName, themeEmphasisToken, themeExtraToken, themeNames, themeToken, } from './theme.js';
|
|
42
44
|
import { presetLabel, profileFromArgv } from './preset-label.js';
|
|
43
45
|
import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
44
46
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
45
47
|
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
|
|
46
48
|
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
47
|
-
import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
|
|
49
|
+
import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, initialQuestionSelection, questionOptionMarker, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
|
|
48
50
|
import { commandSuggestions, localizedCommands } from './commands.js';
|
|
49
51
|
import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
|
|
50
52
|
import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
|
|
51
53
|
import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
|
|
54
|
+
import { asciiFallbackEnabled } from './platform.js';
|
|
52
55
|
import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
|
|
53
56
|
import { sameSessionRoute, sessionRouteInput } from './session-route.js';
|
|
54
57
|
import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
|
|
@@ -63,7 +66,7 @@ import { displayToolName, subagentCourtesyName } from './job-label.js';
|
|
|
63
66
|
import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
|
|
64
67
|
import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
|
|
65
68
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
66
|
-
import { clipAnsiToWidth,
|
|
69
|
+
import { clipAnsiToWidth, cursorVisualPosition, displayWidth, fmtElapsedCompact, foldInputView, highlightAnsiNeedle, hrefAtColumn, lastCodePoints, osc52Clipboard, padToWidth, paintSegmentedLine, paintedLinkHits, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, searchContains, setAmbiguousWidthMeasured, setAmbiguousWidthReserve, shimmerText, sliceCodePoints, stripAnsi, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
|
|
67
70
|
import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
|
|
68
71
|
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';
|
|
69
72
|
import { TerminalInputGuard } from './terminal-input.js';
|
|
@@ -231,6 +234,21 @@ function isModelsStep(step) {
|
|
|
231
234
|
const COMPACTION_STALE_MS = 15 * 60_000;
|
|
232
235
|
/** How many `<family>\0<model>` → row decisions are remembered. */
|
|
233
236
|
const FAMILY_OWNER_CACHE_MAX = 512;
|
|
237
|
+
/** Lifecycle handle for a mounted interactive terminal channel. */
|
|
238
|
+
/**
|
|
239
|
+
* Row fields the display rendering reads, for the per-row render cache.
|
|
240
|
+
*
|
|
241
|
+
* Kept next to the cache's fingerprint rather than beside the row type: the list
|
|
242
|
+
* only has to be complete for *rendering* purposes, and a single place makes it
|
|
243
|
+
* obvious what to extend when the rendering starts reading something new.
|
|
244
|
+
*/
|
|
245
|
+
const SCALAR_KEY_FIELDS = [
|
|
246
|
+
'kind', 'text', 'summary', 'detail', 'header', 'title', 'output', 'planMarkdown',
|
|
247
|
+
'blockedReason', 'diff', 'command', 'expanded', 'archived', 'status', 'phase', 'signal',
|
|
248
|
+
'more', 'startedAt', 'endedAt', 'flipUntil', 'modelProvider', 'provider', 'model', 'id', 'callId',
|
|
249
|
+
];
|
|
250
|
+
/** Row fields holding structures the rendering walks; compared one level deep. */
|
|
251
|
+
const NESTED_KEY_FIELDS = ['todos', 'sources', 'intent'];
|
|
234
252
|
/**
|
|
235
253
|
* A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
|
|
236
254
|
* Locale-neutral on purpose — it reads the same in both catalogs.
|
|
@@ -282,10 +300,6 @@ const LINE_MODE_INSPECT_LINES = 80;
|
|
|
282
300
|
* a changed word is marked without stepping outside the muted palette. The
|
|
283
301
|
* fills they lighten live in `styleLine` (`diff-add` / `diff-del`).
|
|
284
302
|
*/
|
|
285
|
-
const DIFF_EMPHASIS_SGR = {
|
|
286
|
-
'diff-add': '38;2;198;232;190;48;2;34;72;44',
|
|
287
|
-
'diff-del': '38;2;246;206;206;48;2;86;34;34',
|
|
288
|
-
};
|
|
289
303
|
const STALL_WARNING_MS = 60000;
|
|
290
304
|
/** Bytes already queued for the terminal before a frame is skipped instead. */
|
|
291
305
|
const STDOUT_BACKLOG_BYTES = 32 * 1024;
|
|
@@ -682,11 +696,146 @@ export class SshTui {
|
|
|
682
696
|
commandSuggestions = [];
|
|
683
697
|
suggestionIndex = 0;
|
|
684
698
|
focusedRow = null;
|
|
699
|
+
/**
|
|
700
|
+
* The active palette. Roles resolve through `theme.ts`, so switching themes is
|
|
701
|
+
* a repaint, not a re-render — no row holds a colour of its own.
|
|
702
|
+
*/
|
|
703
|
+
theme = resolveTheme(undefined);
|
|
704
|
+
/**
|
|
705
|
+
* Rendered display lines per row, keyed by the row object.
|
|
706
|
+
*
|
|
707
|
+
* Every frame used to re-render the whole transcript — markdown parsing, card
|
|
708
|
+
* layout, wrapping and clipping for thousands of rows — to show the twenty on
|
|
709
|
+
* screen. Measured on a 5000-row session that was ~550 ms per frame, which is
|
|
710
|
+
* what "rendering feels slower the longer the session runs" was: a keystroke
|
|
711
|
+
* costs the same as a full repaint because both redo all of it.
|
|
712
|
+
*
|
|
713
|
+
* The cache is `WeakMap`-keyed on the row object, so a replaced row starts a new
|
|
714
|
+
* entry and an unreferenced one is collected. What each entry stores is a
|
|
715
|
+
* *fingerprint* of every input the row's rendering depends on plus the lines it
|
|
716
|
+
* produced; a row whose fingerprint is unchanged replays instead of re-rendering,
|
|
717
|
+
* which is the common case — during a turn only the streaming row changes.
|
|
718
|
+
*/
|
|
719
|
+
displayRowCache = new WeakMap();
|
|
720
|
+
/** Fingerprint of the state that affects *every* row's rendering. */
|
|
721
|
+
displayBaseKey(width) {
|
|
722
|
+
return [
|
|
723
|
+
width,
|
|
724
|
+
String(this.isCompactView()),
|
|
725
|
+
this.showReasoning === true ? 'r' : '-',
|
|
726
|
+
this.searchIndex,
|
|
727
|
+
this.searchHits.length,
|
|
728
|
+
this.theme.name,
|
|
729
|
+
this.colorDepth,
|
|
730
|
+
this.focusedRow === null ? '' : String(this.focusedRow.kind ?? 'block'),
|
|
731
|
+
String(this.paintTailBudget),
|
|
732
|
+
this.pendingReveal === undefined ? '' : String(this.pendingReveal),
|
|
733
|
+
asciiFallbackEnabled() ? 'ascii' : 'utf8',
|
|
734
|
+
].join('|');
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Fingerprint of one tool burst.
|
|
738
|
+
*
|
|
739
|
+
* A burst is a *group of tool rows drawn inside the reply above them*, so the
|
|
740
|
+
* reply's cached lines contain cards belonging to rows the reply's own
|
|
741
|
+
* fingerprint never sees. Without this, the reply kept replaying its cached
|
|
742
|
+
* burst: a finished tool went on saying "processing" and the stale card stayed
|
|
743
|
+
* on screen beside the live one — the residue a reader reported twice.
|
|
744
|
+
*/
|
|
745
|
+
static burstKey(groups) {
|
|
746
|
+
const one = (rows) => rows.map(row => {
|
|
747
|
+
const loose = row;
|
|
748
|
+
const output = typeof loose.output === 'string' ? loose.output.length : 0;
|
|
749
|
+
return `${String(loose.callId ?? '')}/${String(loose.status ?? '')}/${output}`;
|
|
750
|
+
}).join(',');
|
|
751
|
+
return `${one(groups.edits)}#${one(groups.calls)}`;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Whether a row's rendering depends on the clock.
|
|
755
|
+
*
|
|
756
|
+
* A running card draws a spinner and an elapsed time, and a streaming row is
|
|
757
|
+
* being appended to: their *fields* may not change between two frames while
|
|
758
|
+
* their lines must. Such a row gets a tick in its cache key, so it re-renders
|
|
759
|
+
* every frame and becomes cacheable again once it settles. A frozen spinner was
|
|
760
|
+
* the alternative — and, once the same card was also drawn live elsewhere, the
|
|
761
|
+
* duplicate a reader actually reported.
|
|
762
|
+
*/
|
|
763
|
+
static isLiveRow(row) {
|
|
764
|
+
const loose = row;
|
|
765
|
+
if (String(loose.kind ?? '').startsWith('streaming'))
|
|
766
|
+
return true;
|
|
767
|
+
if (loose.status === 'running')
|
|
768
|
+
return true;
|
|
769
|
+
if (loose.endedAt === undefined && loose.startedAt !== undefined)
|
|
770
|
+
return true;
|
|
771
|
+
const flipUntil = loose.flipUntil;
|
|
772
|
+
return typeof flipUntil === 'number' && flipUntil > Date.now();
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Fingerprint of everything one row's rendering reads.
|
|
776
|
+
*
|
|
777
|
+
* Written by hand rather than hashing the object: the rendering reads a known
|
|
778
|
+
* set of fields, and a generic walk would cost more per frame than the render
|
|
779
|
+
* it is meant to avoid. Scalar fields are compared by value; nested arrays the
|
|
780
|
+
* rendering walks (todos, sources, intent) get a shallow signature of their own
|
|
781
|
+
* scalars, because those are updated in place.
|
|
782
|
+
*
|
|
783
|
+
* The list is a contract: a field the rendering starts reading must be added
|
|
784
|
+
* here, or a frame replays stale lines. A test mutates one field in place and
|
|
785
|
+
* asserts the next frame notices, which is the pattern to copy for a new field.
|
|
786
|
+
*/
|
|
787
|
+
static rowKey(row) {
|
|
788
|
+
const loose = row;
|
|
789
|
+
let key = '';
|
|
790
|
+
for (const field of SCALAR_KEY_FIELDS) {
|
|
791
|
+
const value = loose[field];
|
|
792
|
+
key += typeof value === 'string' ? `${value.length}:${value.charCodeAt(0)}:${value.charCodeAt(value.length - 1)};`
|
|
793
|
+
: value === undefined ? '-;'
|
|
794
|
+
: `${String(value)};`;
|
|
795
|
+
}
|
|
796
|
+
for (const field of NESTED_KEY_FIELDS) {
|
|
797
|
+
const value = loose[field];
|
|
798
|
+
if (value === undefined) {
|
|
799
|
+
key += '-;';
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (!Array.isArray(value)) {
|
|
803
|
+
key += `o${Object.keys(value).length};`;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
key += `a${value.length}:`;
|
|
807
|
+
// Bounded: a list longer than this changes its length when it changes, and
|
|
808
|
+
// walking hundreds of items per frame would defeat the cache.
|
|
809
|
+
for (const item of value.slice(0, 24)) {
|
|
810
|
+
if (item === null || typeof item !== 'object') {
|
|
811
|
+
key += String(item) + ',';
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
for (const [name, inner] of Object.entries(item)) {
|
|
815
|
+
key += name + '=' + (typeof inner === 'string' ? inner.length : String(inner)) + ',';
|
|
816
|
+
}
|
|
817
|
+
key += '|';
|
|
818
|
+
}
|
|
819
|
+
key += ';';
|
|
820
|
+
}
|
|
821
|
+
return key;
|
|
822
|
+
}
|
|
823
|
+
static rowRendersEqual(row, entry, key) {
|
|
824
|
+
return entry !== undefined && entry.key === key;
|
|
825
|
+
}
|
|
685
826
|
/**
|
|
686
827
|
* The text of the last prompt the user sent, kept so an opted-in retry can
|
|
687
828
|
* send the same thing again after a provider-side auth failure. Cleared when
|
|
688
829
|
* the retry fires, which is what bounds it to one attempt per user message.
|
|
689
830
|
*/
|
|
831
|
+
/**
|
|
832
|
+
* Whether this session ever saw the reader's input.
|
|
833
|
+
*
|
|
834
|
+
* A fresh launch creates a session before anything is typed; quitting straight
|
|
835
|
+
* away leaves an artifact the TUI hides but other profiles' menus list. On the
|
|
836
|
+
* way out, a session that never saw input is deleted (see `session-blank.ts`).
|
|
837
|
+
*/
|
|
838
|
+
sawUserInput = false;
|
|
690
839
|
lastUserText = '';
|
|
691
840
|
/** Set on every turn/start; a retry consumes it. */
|
|
692
841
|
authRetryArmed = false;
|
|
@@ -868,6 +1017,11 @@ export class SshTui {
|
|
|
868
1017
|
// The palette the terminal can take, decided once: a truecolor pair on an
|
|
869
1018
|
// 8-colour terminal is not a cosmetic loss, it is the wrong colour.
|
|
870
1019
|
this.colorDepth = config.color === false ? 'none' : colorDepth(process.env);
|
|
1020
|
+
// The theme is a live setting: `DSH_TUI_THEME` wins for one launch, the
|
|
1021
|
+
// `ssh-tui` settings section remembers the user's choice across them.
|
|
1022
|
+
// Publish as well as store: the footer, plan dock, tool cards and markdown
|
|
1023
|
+
// renderers have no session handle and read the active palette.
|
|
1024
|
+
this.theme = setActiveTheme(process.env.DSH_TUI_THEME ?? this.readThemeName());
|
|
871
1025
|
this.color = this.colorDepth !== 'none';
|
|
872
1026
|
this.lastSessionRoute = config.restoredRoute;
|
|
873
1027
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
@@ -1277,7 +1431,10 @@ export class SshTui {
|
|
|
1277
1431
|
addDisplay(this.selectLine(styled, this.focusedRow === anchor), anchor);
|
|
1278
1432
|
// A collapsed burst is all the reader sees, so the two things that decide
|
|
1279
1433
|
// whether to expand belong on it: which files moved, and what failed.
|
|
1280
|
-
|
|
1434
|
+
// The breakdown is for a burst that touched *several* files: with one
|
|
1435
|
+
// file the header below already carries its path and its diffstat, and
|
|
1436
|
+
// repeating them printed the same line twice.
|
|
1437
|
+
if (kind === 'edits' && groups.edits.length > 1) {
|
|
1281
1438
|
const stats = compactFileStats(groups.edits);
|
|
1282
1439
|
const shown = stats.slice(0, MAX_COMPACT_FILE_STATS);
|
|
1283
1440
|
const parts = shown.map(stat => `${stat.path} ${diffStatToken(stat.add, stat.del)}`.trim());
|
|
@@ -1289,7 +1446,9 @@ export class SshTui {
|
|
|
1289
1446
|
}
|
|
1290
1447
|
}
|
|
1291
1448
|
for (const line of compactFailureLines(items)) {
|
|
1292
|
-
|
|
1449
|
+
// Same grammar as the header: the state ball is what marks a line as a
|
|
1450
|
+
// tool's, and a failure that is only red text loses that reading.
|
|
1451
|
+
addDisplay(this.styleLine('error', truncateToWidth(` ● ${line}`, width)), anchor);
|
|
1293
1452
|
}
|
|
1294
1453
|
return;
|
|
1295
1454
|
}
|
|
@@ -2078,6 +2237,15 @@ export class SshTui {
|
|
|
2078
2237
|
onRtt: (rttMs) => {
|
|
2079
2238
|
this.applyProbedRtt(rttMs);
|
|
2080
2239
|
},
|
|
2240
|
+
onMetrics: metrics => {
|
|
2241
|
+
// The relay measured the terminal this session is displayed on. Adopt it
|
|
2242
|
+
// and repaint from scratch: every cached line was measured with the old
|
|
2243
|
+
// table, so a diff against them would compare different widths.
|
|
2244
|
+
setAmbiguousWidthMeasured(metrics.wide);
|
|
2245
|
+
setAmbiguousWidthReserve(metrics.reserve);
|
|
2246
|
+
this.forceFullPaint = true;
|
|
2247
|
+
this.markDirty();
|
|
2248
|
+
},
|
|
2081
2249
|
onDetach: (info) => {
|
|
2082
2250
|
this.handleDisplayDetach(info);
|
|
2083
2251
|
},
|
|
@@ -3219,7 +3387,74 @@ export class SshTui {
|
|
|
3219
3387
|
&& this.rows.length > this.paintTailBudget + 8;
|
|
3220
3388
|
const historyStart = skipMiddle ? Math.max(0, this.rows.length - this.paintTailBudget) : 0;
|
|
3221
3389
|
let paintedLeadingCompact = skipMiddle;
|
|
3390
|
+
// Per-row render cache. A row whose fingerprint is unchanged replays its
|
|
3391
|
+
// lines instead of re-rendering them, which is what turns a frame from
|
|
3392
|
+
// "parse every markdown block in the transcript" into "parse the one that
|
|
3393
|
+
// changed". The base key covers the state every row reads (width, view mode,
|
|
3394
|
+
// focus, search, theme, ascii fallback); the row key covers its own fields.
|
|
3395
|
+
const displayBase = this.displayBaseKey(width);
|
|
3396
|
+
// The row body has several `continue`s, so its cache entry is written when the
|
|
3397
|
+
// *next* row starts (and once after the loop) rather than at every exit.
|
|
3398
|
+
let pendingFinish;
|
|
3399
|
+
const flushPending = () => {
|
|
3400
|
+
const finish = pendingFinish;
|
|
3401
|
+
pendingFinish = undefined;
|
|
3402
|
+
finish?.();
|
|
3403
|
+
};
|
|
3404
|
+
// Two of the row body's inputs come from *other* rows: whether this is still
|
|
3405
|
+
// in the "leading" stretch of a compact view (which flips once an assistant
|
|
3406
|
+
// or user row has been painted) and whether the row is folded into a tool
|
|
3407
|
+
// burst rendered by the reply above it. Leaving them out of the cache key is
|
|
3408
|
+
// what put a second copy of a running card on screen — the stale entry
|
|
3409
|
+
// replayed lines for a row that now renders inside its reply's burst.
|
|
3410
|
+
let leadingPhase = skipMiddle;
|
|
3411
|
+
// One tick per frame, shared by every live row: coarse enough not to churn a
|
|
3412
|
+
// key mid-frame, fine enough that a spinner or an elapsed time moves.
|
|
3413
|
+
const liveTick = Math.floor(Date.now() / 200);
|
|
3222
3414
|
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
|
|
3415
|
+
flushPending();
|
|
3416
|
+
const cachedRow = this.rows[rowIndex];
|
|
3417
|
+
const inBurst = compact && cachedRow !== undefined && compactBurstByReply.has(cachedRow);
|
|
3418
|
+
let cacheKey;
|
|
3419
|
+
if (cachedRow !== undefined && !skipMiddle) {
|
|
3420
|
+
const live = SshTui.isLiveRow(cachedRow);
|
|
3421
|
+
// The burst belongs to this row's rendering even though its rows are
|
|
3422
|
+
// elsewhere in the transcript, so its content is part of the key.
|
|
3423
|
+
const burst = inBurst ? compactBurstByReply.get(cachedRow) : undefined;
|
|
3424
|
+
const leadingBurst = compact && !leadingPhase && (cachedRow.kind === 'assistant' || cachedRow.kind === 'user')
|
|
3425
|
+
? compactBursts.find(candidate => candidate.after === undefined)
|
|
3426
|
+
: undefined;
|
|
3427
|
+
const burstKey = burst === undefined && leadingBurst === undefined
|
|
3428
|
+
? '-'
|
|
3429
|
+
: `${burst === undefined ? '' : SshTui.burstKey(burst.groups)}|${leadingBurst === undefined ? '' : SshTui.burstKey(leadingBurst.groups)}`;
|
|
3430
|
+
cacheKey = `${displayBase}|${rowIndex === 4 ? 'fold' : ''}|${leadingPhase ? 'lead' : '-'}|${live ? `t${liveTick}` : 'settled'}|${burstKey}|${SshTui.rowKey(cachedRow)}`;
|
|
3431
|
+
const entry = this.displayRowCache.get(cachedRow);
|
|
3432
|
+
if (SshTui.rowRendersEqual(cachedRow, entry, cacheKey)) {
|
|
3433
|
+
for (const line of entry.lines)
|
|
3434
|
+
display.push(line);
|
|
3435
|
+
for (const ref of entry.refs)
|
|
3436
|
+
displayRefs.push(ref);
|
|
3437
|
+
for (const gutter of entry.gutters)
|
|
3438
|
+
displayGutters.push(gutter);
|
|
3439
|
+
// The body flips this flag when it paints an assistant/user row; the
|
|
3440
|
+
// replay has to do the same or the next row's key would lie.
|
|
3441
|
+
if (compact && (cachedRow.kind === 'assistant' || cachedRow.kind === 'user'))
|
|
3442
|
+
leadingPhase = true;
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
const markStart = { line: display.length, ref: displayRefs.length, gutter: displayGutters.length };
|
|
3447
|
+
const finishRow = () => {
|
|
3448
|
+
if (cacheKey === undefined || cachedRow === undefined)
|
|
3449
|
+
return;
|
|
3450
|
+
this.displayRowCache.set(cachedRow, {
|
|
3451
|
+
key: cacheKey,
|
|
3452
|
+
lines: display.slice(markStart.line),
|
|
3453
|
+
refs: displayRefs.slice(markStart.ref),
|
|
3454
|
+
gutters: displayGutters.slice(markStart.gutter),
|
|
3455
|
+
});
|
|
3456
|
+
};
|
|
3457
|
+
pendingFinish = finishRow;
|
|
3223
3458
|
if (skipMiddle && rowIndex >= 4 && rowIndex < historyStart) {
|
|
3224
3459
|
if (rowIndex === 4)
|
|
3225
3460
|
addDisplay(this.styleLine('system', t('history.folded')));
|
|
@@ -3457,6 +3692,13 @@ export class SshTui {
|
|
|
3457
3692
|
}
|
|
3458
3693
|
}
|
|
3459
3694
|
}
|
|
3695
|
+
// Close the last row's cache entry here, and only here: everything below
|
|
3696
|
+
// belongs to no row. Flushing after the live tail instead stored the tail in
|
|
3697
|
+
// the last row's entry, so every later frame replayed a copy of the wait card
|
|
3698
|
+
// at the elapsed time of the frame that stored it — a "processing" line
|
|
3699
|
+
// frozen at 0s next to the live one, and a new copy each time another row
|
|
3700
|
+
// became the last one.
|
|
3701
|
+
flushPending();
|
|
3460
3702
|
if (this.streaming !== undefined) {
|
|
3461
3703
|
if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
|
|
3462
3704
|
const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
@@ -3709,7 +3951,9 @@ export class SshTui {
|
|
|
3709
3951
|
const option = options[index];
|
|
3710
3952
|
if (option === undefined)
|
|
3711
3953
|
continue;
|
|
3712
|
-
|
|
3954
|
+
// `●` follows the cursor: it is the row Enter submits. Ticked rows in a
|
|
3955
|
+
// multi-select list show `✓` so both facts stay visible at once.
|
|
3956
|
+
const marker = questionOptionMarker(d, index);
|
|
3713
3957
|
const key = QUESTION_OPTION_KEYS[index] ?? '↕';
|
|
3714
3958
|
const focused = index === d.cursor ? '›' : ' ';
|
|
3715
3959
|
const recommended = option.label === approve ? t('dialog.recommended') : '';
|
|
@@ -3760,7 +4004,9 @@ export class SshTui {
|
|
|
3760
4004
|
suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
|
|
3761
4005
|
}
|
|
3762
4006
|
const promptPlain = this.color ? '❯ ' : '> ';
|
|
3763
|
-
const prompt = this.color
|
|
4007
|
+
const prompt = this.color
|
|
4008
|
+
? `\x1b[${themeExtraToken(this.theme, 'accent')}m${promptPlain.trimEnd()}\x1b[0m `
|
|
4009
|
+
: promptPlain;
|
|
3764
4010
|
const promptWidth = displayWidth(promptPlain);
|
|
3765
4011
|
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
3766
4012
|
const inputTextWidth = Math.max(1, width - promptWidth);
|
|
@@ -3931,11 +4177,11 @@ export class SshTui {
|
|
|
3931
4177
|
let statusLine = this.styleLine('system', statusText);
|
|
3932
4178
|
if (this.color && this.contextPressure !== undefined) {
|
|
3933
4179
|
const ring = formatContextPressureRing(this.contextPressure.percent);
|
|
3934
|
-
statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[
|
|
4180
|
+
statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[${themeToken(this.theme, 'system')}m`);
|
|
3935
4181
|
}
|
|
3936
4182
|
if (this.color) {
|
|
3937
4183
|
const chip = identity.find(part => part.startsWith('sub:')) ?? '';
|
|
3938
|
-
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || '
|
|
4184
|
+
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || themeToken(this.theme, 'system'), this.colorDepth);
|
|
3939
4185
|
}
|
|
3940
4186
|
// The strip: one loss order for the groups a narrow terminal can do without.
|
|
3941
4187
|
// Health leads because it is the only group that reports a broken install;
|
|
@@ -4591,31 +4837,17 @@ export class SshTui {
|
|
|
4591
4837
|
return code === '' ? ' │ ' : `\x1b[${code}m │ `;
|
|
4592
4838
|
}
|
|
4593
4839
|
mutedSgr() {
|
|
4594
|
-
return this.color ? downgradeSgr('
|
|
4840
|
+
return this.color ? downgradeSgr(themeToken(this.theme, 'system'), this.colorDepth) : '';
|
|
4595
4841
|
}
|
|
4596
4842
|
styleLine(kind, text) {
|
|
4597
4843
|
const safe = sanitizeTerminalText(text);
|
|
4598
4844
|
if (!this.color)
|
|
4599
4845
|
return safe;
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
// Codex-like: muted add/del that blend into the terminal background.
|
|
4606
|
-
kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
|
|
4607
|
-
kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
|
|
4608
|
-
kind === 'diff-path' ? '1;36' :
|
|
4609
|
-
kind === 'todo-done' ? '2;32' :
|
|
4610
|
-
kind === 'todo-active' ? '1;36' :
|
|
4611
|
-
kind === 'todo-failed' ? '31' :
|
|
4612
|
-
kind === 'todo-skipped' ? '2;33' :
|
|
4613
|
-
kind === 'todo-pending' ? '90' :
|
|
4614
|
-
kind === 'plan-dock' ? '38;5;180' :
|
|
4615
|
-
kind === 'subagent-header' ? '38;5;141' :
|
|
4616
|
-
kind === 'diag' ? '2;36' :
|
|
4617
|
-
kind === 'error' ? '31' :
|
|
4618
|
-
'90';
|
|
4846
|
+
// Roles, not colours: `theme.ts` owns the palette, this method owns the
|
|
4847
|
+
// escaping. Primary text and tool output carry an empty token on purpose —
|
|
4848
|
+
// they take the terminal's own foreground, which is the only choice that is
|
|
4849
|
+
// readable on both light and dark terminals.
|
|
4850
|
+
const requested = themeToken(this.theme, kind);
|
|
4619
4851
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4620
4852
|
// Nothing left to paint: the text stands on its own.
|
|
4621
4853
|
if (code === '')
|
|
@@ -4633,7 +4865,7 @@ export class SshTui {
|
|
|
4633
4865
|
* the inverse video stays for that case.
|
|
4634
4866
|
*/
|
|
4635
4867
|
styleEmphasisedPiece(kind, text) {
|
|
4636
|
-
const requested =
|
|
4868
|
+
const requested = themeEmphasisToken(this.theme, kind);
|
|
4637
4869
|
if (requested !== undefined && this.color) {
|
|
4638
4870
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4639
4871
|
if (code !== '')
|
|
@@ -5062,6 +5294,9 @@ export class SshTui {
|
|
|
5062
5294
|
if (this.hostApprovalPolicy === 'never')
|
|
5063
5295
|
this.warnApprovalMismatch();
|
|
5064
5296
|
break;
|
|
5297
|
+
case 'user/message':
|
|
5298
|
+
this.sawUserInput = true;
|
|
5299
|
+
break;
|
|
5065
5300
|
case 'turn/start':
|
|
5066
5301
|
this.stalledWarningShown = false;
|
|
5067
5302
|
this.turnSawOutput = false;
|
|
@@ -6627,7 +6862,7 @@ export class SshTui {
|
|
|
6627
6862
|
this.removeQueuedDialog(dialog);
|
|
6628
6863
|
dialog.resolve('cancel');
|
|
6629
6864
|
}
|
|
6630
|
-
openQuestion(question, index, total, resolve, reject, preselected, matchKeys) {
|
|
6865
|
+
openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor) {
|
|
6631
6866
|
// A list opens with its first option already chosen: Enter then answers that
|
|
6632
6867
|
// default for a single- and a multi-select question alike, instead of
|
|
6633
6868
|
// coming back with an empty selection. Esc stays the explicit cancel, and a
|
|
@@ -6636,14 +6871,26 @@ export class SshTui {
|
|
|
6636
6871
|
const initial = preselected !== undefined && preselected >= 0 && preselected < optionCount
|
|
6637
6872
|
? preselected
|
|
6638
6873
|
: optionCount > 0 ? 0 : -1;
|
|
6874
|
+
// A single-select list opens with its first option chosen, so Enter answers
|
|
6875
|
+
// that default (and moving the highlight moves the `●`).
|
|
6876
|
+
//
|
|
6877
|
+
// A **multi-select** list must open with nothing chosen. It used to inherit
|
|
6878
|
+
// the same default, and the result was the worst kind of mis-answer: the
|
|
6879
|
+
// reader moved the highlight to the row they wanted, pressed Enter, and got
|
|
6880
|
+
// the first row — because `●` still sat on it. `questionSubmit` already
|
|
6881
|
+
// falls back to the highlighted row when nothing is marked, which is exactly
|
|
6882
|
+
// the "this one" the reader meant; the inherited selection was what kept that
|
|
6883
|
+
// fallback from firing. The caller can still pass an explicit preselection.
|
|
6884
|
+
const initialSelection = initialQuestionSelection(question, preselected);
|
|
6639
6885
|
const dialog = {
|
|
6640
6886
|
kind: 'questions',
|
|
6641
6887
|
question,
|
|
6642
6888
|
index,
|
|
6643
6889
|
total,
|
|
6644
|
-
selected: new Set(
|
|
6890
|
+
selected: new Set(initialSelection),
|
|
6645
6891
|
cursor: initial >= 0 ? initial : 0,
|
|
6646
6892
|
...(matchKeys === undefined ? {} : { matchKeys }),
|
|
6893
|
+
...(onCursor === undefined ? {} : { onCursor }),
|
|
6647
6894
|
resolve: (selection) => {
|
|
6648
6895
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
6649
6896
|
},
|
|
@@ -6655,9 +6902,9 @@ export class SshTui {
|
|
|
6655
6902
|
return dialog;
|
|
6656
6903
|
}
|
|
6657
6904
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
6658
|
-
askQuestion(question, index = 0, total = 1, preselected, matchKeys) {
|
|
6905
|
+
askQuestion(question, index = 0, total = 1, preselected, matchKeys, onCursor) {
|
|
6659
6906
|
return new Promise((resolve, reject) => {
|
|
6660
|
-
this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys);
|
|
6907
|
+
this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor);
|
|
6661
6908
|
});
|
|
6662
6909
|
}
|
|
6663
6910
|
/** The stored llm-pi-ai profile for one provider route, when settings provide one. */
|
|
@@ -8059,6 +8306,13 @@ export class SshTui {
|
|
|
8059
8306
|
reportAuthFailure(message) {
|
|
8060
8307
|
if (this.replaying)
|
|
8061
8308
|
return;
|
|
8309
|
+
// The reasoning-replay 400 is an upstream bug with its own workarounds, so it
|
|
8310
|
+
// gets its own sentence rather than the auth advice.
|
|
8311
|
+
if (isReasoningReplayFailure(message)) {
|
|
8312
|
+
this.pushRow({ kind: 'system', text: t('auth.reasoningReplay') });
|
|
8313
|
+
this.markDirty();
|
|
8314
|
+
return;
|
|
8315
|
+
}
|
|
8062
8316
|
const classified = classifyAuthFailure(message);
|
|
8063
8317
|
if (classified === undefined)
|
|
8064
8318
|
return;
|
|
@@ -8120,6 +8374,118 @@ export class SshTui {
|
|
|
8120
8374
|
}
|
|
8121
8375
|
return false;
|
|
8122
8376
|
}
|
|
8377
|
+
/** Whether anything the reader typed reached this session (used on exit). */
|
|
8378
|
+
sessionHadUserInput() {
|
|
8379
|
+
return this.sawUserInput;
|
|
8380
|
+
}
|
|
8381
|
+
/**
|
|
8382
|
+
* `/cleanup [--dry-run]` — delete sessions that never saw user input.
|
|
8383
|
+
*
|
|
8384
|
+
* Every fresh start creates a session, so quitting without typing leaves an
|
|
8385
|
+
* artifact behind. The picker hides those, but the web session list reads the
|
|
8386
|
+
* same files without that filter, which is how an unused session still shows
|
|
8387
|
+
* up in another profile's menu. This walks the whole history once and deletes
|
|
8388
|
+
* the blank ones (the listing prunes as it resolves, which is the same rule the
|
|
8389
|
+
* picker applies).
|
|
8390
|
+
*/
|
|
8391
|
+
async runCleanupCommand(arg) {
|
|
8392
|
+
const dryRun = arg.trim() === '--dry-run';
|
|
8393
|
+
if (arg.trim() !== '' && !dryRun) {
|
|
8394
|
+
this.pushRow({ kind: 'error', text: t('cleanup.usage') });
|
|
8395
|
+
this.markDirty();
|
|
8396
|
+
return;
|
|
8397
|
+
}
|
|
8398
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
8399
|
+
if (persistence === undefined) {
|
|
8400
|
+
this.pushRow({ kind: 'error', text: t('cleanup.unavailable') });
|
|
8401
|
+
this.markDirty();
|
|
8402
|
+
return;
|
|
8403
|
+
}
|
|
8404
|
+
this.pushRow({ kind: 'system', text: t(dryRun ? 'cleanup.scanning' : 'cleanup.working') });
|
|
8405
|
+
this.markDirty();
|
|
8406
|
+
const result = await pruneBlankSessions(persistence, String(this.agent.session.id), { dryRun });
|
|
8407
|
+
this.pushRow({
|
|
8408
|
+
kind: 'system',
|
|
8409
|
+
text: t(dryRun ? 'cleanup.dryRun' : 'cleanup.done', {
|
|
8410
|
+
pruned: String(result.pruned),
|
|
8411
|
+
kept: String(result.kept),
|
|
8412
|
+
unreadable: String(result.unreadable),
|
|
8413
|
+
}),
|
|
8414
|
+
});
|
|
8415
|
+
this.markDirty();
|
|
8416
|
+
}
|
|
8417
|
+
/** The theme saved in the `ssh-tui` settings section, if any. */
|
|
8418
|
+
readThemeName() {
|
|
8419
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
8420
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
8421
|
+
return undefined;
|
|
8422
|
+
const value = raw.theme;
|
|
8423
|
+
const name = String(value ?? '').trim();
|
|
8424
|
+
return name === '' ? undefined : name;
|
|
8425
|
+
}
|
|
8426
|
+
/**
|
|
8427
|
+
* `/theme [name]` — list the palettes, or switch to one and remember it.
|
|
8428
|
+
*
|
|
8429
|
+
* Switching repaints from the row cache, so it costs one frame rather than a
|
|
8430
|
+
* re-render of the transcript; `mono` exists for terminals where colour is the
|
|
8431
|
+
* problem rather than the answer, and it keeps every difference as an
|
|
8432
|
+
* attribute instead of throwing the difference away.
|
|
8433
|
+
*/
|
|
8434
|
+
async runThemeCommand(arg) {
|
|
8435
|
+
const wanted = arg.trim().toLowerCase();
|
|
8436
|
+
if (wanted === '') {
|
|
8437
|
+
// A picker, not a list: ↑/↓ paints the candidate palette straight away, so
|
|
8438
|
+
// the reader judges it on their own transcript (and their own terminal)
|
|
8439
|
+
// before committing. Esc restores what they had — the same browse-preview-
|
|
8440
|
+
// revert flow Crush's theme picker offers, and the reason `onCursor` exists.
|
|
8441
|
+
const names = themeNames();
|
|
8442
|
+
const before = this.theme;
|
|
8443
|
+
const apply = (name) => {
|
|
8444
|
+
this.theme = setActiveTheme(name);
|
|
8445
|
+
// Every cached line carries a token from the old palette: repaint from
|
|
8446
|
+
// scratch rather than diffing against colours that no longer apply.
|
|
8447
|
+
this.forceFullPaint = true;
|
|
8448
|
+
this.markDirty();
|
|
8449
|
+
};
|
|
8450
|
+
let picked;
|
|
8451
|
+
try {
|
|
8452
|
+
const answer = await this.askQuestion({
|
|
8453
|
+
id: 'theme-pick',
|
|
8454
|
+
question: t('theme.pick'),
|
|
8455
|
+
options: names.map(name => ({ label: name, description: themeByName(name)?.name === before.name ? t('theme.currentTag') : '' })),
|
|
8456
|
+
}, 0, 1, Math.max(0, names.indexOf(before.name)), undefined, cursor => {
|
|
8457
|
+
const name = names[cursor];
|
|
8458
|
+
if (name !== undefined)
|
|
8459
|
+
apply(name);
|
|
8460
|
+
});
|
|
8461
|
+
picked = answer.selected[0];
|
|
8462
|
+
}
|
|
8463
|
+
catch {
|
|
8464
|
+
apply(before.name);
|
|
8465
|
+
this.pushRow({ kind: 'system', text: t('theme.cancelled', { name: before.name }) });
|
|
8466
|
+
this.markDirty();
|
|
8467
|
+
return;
|
|
8468
|
+
}
|
|
8469
|
+
const next = themeByName(picked) ?? before;
|
|
8470
|
+
apply(next.name);
|
|
8471
|
+
await this.mergeUiSettings({ theme: next.name });
|
|
8472
|
+
this.pushRow({ kind: 'system', text: t('theme.switched', { name: next.name }) });
|
|
8473
|
+
this.markDirty();
|
|
8474
|
+
return;
|
|
8475
|
+
}
|
|
8476
|
+
const next = themeByName(wanted);
|
|
8477
|
+
if (next === undefined) {
|
|
8478
|
+
this.pushRow({ kind: 'error', text: t('theme.unknown', { name: wanted, known: themeNames().join(', ') }) });
|
|
8479
|
+
this.markDirty();
|
|
8480
|
+
return;
|
|
8481
|
+
}
|
|
8482
|
+
this.theme = setActiveTheme(next.name);
|
|
8483
|
+
await this.mergeUiSettings({ theme: next.name });
|
|
8484
|
+
// A theme change touches every cached line, so the whole frame is stale.
|
|
8485
|
+
this.forceFullPaint = true;
|
|
8486
|
+
this.pushRow({ kind: 'system', text: t('theme.switched', { name: next.name }) });
|
|
8487
|
+
this.markDirty();
|
|
8488
|
+
}
|
|
8123
8489
|
/** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */
|
|
8124
8490
|
async runRetryAuthCommand(arg) {
|
|
8125
8491
|
const id = arg.trim().toLowerCase();
|
|
@@ -10510,6 +10876,7 @@ export class SshTui {
|
|
|
10510
10876
|
this.history.push(text);
|
|
10511
10877
|
this.historyIndex = this.history.length;
|
|
10512
10878
|
this.historyDraft = '';
|
|
10879
|
+
this.sawUserInput = true;
|
|
10513
10880
|
this.lastUserText = text;
|
|
10514
10881
|
this.input = '';
|
|
10515
10882
|
this.cursor = 0;
|
|
@@ -10673,6 +11040,18 @@ export class SshTui {
|
|
|
10673
11040
|
this.markDirty();
|
|
10674
11041
|
});
|
|
10675
11042
|
break;
|
|
11043
|
+
case 'cleanup':
|
|
11044
|
+
void this.runCleanupCommand(arg).catch((error) => {
|
|
11045
|
+
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'cleanup', error: errorChain(error) }) });
|
|
11046
|
+
this.markDirty();
|
|
11047
|
+
});
|
|
11048
|
+
break;
|
|
11049
|
+
case 'theme':
|
|
11050
|
+
void this.runThemeCommand(arg).catch((error) => {
|
|
11051
|
+
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'theme', error: errorChain(error) }) });
|
|
11052
|
+
this.markDirty();
|
|
11053
|
+
});
|
|
11054
|
+
break;
|
|
10676
11055
|
case 'retryauth':
|
|
10677
11056
|
void this.runRetryAuthCommand(arg).catch((error) => {
|
|
10678
11057
|
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'retryauth', error: errorChain(error) }) });
|
|
@@ -11134,6 +11513,9 @@ export function mountTui(ctx, config) {
|
|
|
11134
11513
|
disconnectPolicy() {
|
|
11135
11514
|
return controller?.currentDisconnectPolicy() ?? 'pause';
|
|
11136
11515
|
},
|
|
11516
|
+
sessionHadUserInput() {
|
|
11517
|
+
return controller?.sessionHadUserInput() ?? false;
|
|
11518
|
+
},
|
|
11137
11519
|
};
|
|
11138
11520
|
}
|
|
11139
11521
|
//# sourceMappingURL=tui.js.map
|