dsh-ssh-tui 0.7.4 → 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 +37 -0
- package/README.md +436 -572
- package/docs/display-mode.md +122 -0
- package/docs/remote-ops.md +104 -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 +128 -0
- package/lib/auth-failure.js.map +1 -0
- package/lib/commands.js +3 -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 +27 -2
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +27 -2
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +74 -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 +535 -37
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +6 -2
- package/lib/types/auth-failure.d.ts +78 -0
- package/lib/types/commands.d.ts +9 -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 +20 -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 +121 -1
- package/package.json +92 -93
package/lib/tui.js
CHANGED
|
@@ -32,22 +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';
|
|
42
|
+
import { classifyAuthFailure, isReasoningReplayFailure } from './auth-failure.js';
|
|
43
|
+
import { resolveTheme, setActiveTheme, themeByName, themeEmphasisToken, themeExtraToken, themeNames, themeToken, } from './theme.js';
|
|
41
44
|
import { presetLabel, profileFromArgv } from './preset-label.js';
|
|
42
45
|
import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
43
46
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
44
47
|
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
|
|
45
48
|
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
46
|
-
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';
|
|
47
50
|
import { commandSuggestions, localizedCommands } from './commands.js';
|
|
48
51
|
import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
|
|
49
52
|
import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
|
|
50
53
|
import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
|
|
54
|
+
import { asciiFallbackEnabled } from './platform.js';
|
|
51
55
|
import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
|
|
52
56
|
import { sameSessionRoute, sessionRouteInput } from './session-route.js';
|
|
53
57
|
import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
|
|
@@ -62,7 +66,7 @@ import { displayToolName, subagentCourtesyName } from './job-label.js';
|
|
|
62
66
|
import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
|
|
63
67
|
import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
|
|
64
68
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
65
|
-
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';
|
|
66
70
|
import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
|
|
67
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';
|
|
68
72
|
import { TerminalInputGuard } from './terminal-input.js';
|
|
@@ -230,6 +234,21 @@ function isModelsStep(step) {
|
|
|
230
234
|
const COMPACTION_STALE_MS = 15 * 60_000;
|
|
231
235
|
/** How many `<family>\0<model>` → row decisions are remembered. */
|
|
232
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'];
|
|
233
252
|
/**
|
|
234
253
|
* A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
|
|
235
254
|
* Locale-neutral on purpose — it reads the same in both catalogs.
|
|
@@ -281,10 +300,6 @@ const LINE_MODE_INSPECT_LINES = 80;
|
|
|
281
300
|
* a changed word is marked without stepping outside the muted palette. The
|
|
282
301
|
* fills they lighten live in `styleLine` (`diff-add` / `diff-del`).
|
|
283
302
|
*/
|
|
284
|
-
const DIFF_EMPHASIS_SGR = {
|
|
285
|
-
'diff-add': '38;2;198;232;190;48;2;34;72;44',
|
|
286
|
-
'diff-del': '38;2;246;206;206;48;2;86;34;34',
|
|
287
|
-
};
|
|
288
303
|
const STALL_WARNING_MS = 60000;
|
|
289
304
|
/** Bytes already queued for the terminal before a frame is skipped instead. */
|
|
290
305
|
const STDOUT_BACKLOG_BYTES = 32 * 1024;
|
|
@@ -681,6 +696,149 @@ export class SshTui {
|
|
|
681
696
|
commandSuggestions = [];
|
|
682
697
|
suggestionIndex = 0;
|
|
683
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
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* The text of the last prompt the user sent, kept so an opted-in retry can
|
|
828
|
+
* send the same thing again after a provider-side auth failure. Cleared when
|
|
829
|
+
* the retry fires, which is what bounds it to one attempt per user message.
|
|
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;
|
|
839
|
+
lastUserText = '';
|
|
840
|
+
/** Set on every turn/start; a retry consumes it. */
|
|
841
|
+
authRetryArmed = false;
|
|
684
842
|
pendingMessages = new Map();
|
|
685
843
|
lastActivity = Date.now();
|
|
686
844
|
lastIdleCtrlCAt = 0;
|
|
@@ -859,6 +1017,11 @@ export class SshTui {
|
|
|
859
1017
|
// The palette the terminal can take, decided once: a truecolor pair on an
|
|
860
1018
|
// 8-colour terminal is not a cosmetic loss, it is the wrong colour.
|
|
861
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());
|
|
862
1025
|
this.color = this.colorDepth !== 'none';
|
|
863
1026
|
this.lastSessionRoute = config.restoredRoute;
|
|
864
1027
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
@@ -1268,7 +1431,10 @@ export class SshTui {
|
|
|
1268
1431
|
addDisplay(this.selectLine(styled, this.focusedRow === anchor), anchor);
|
|
1269
1432
|
// A collapsed burst is all the reader sees, so the two things that decide
|
|
1270
1433
|
// whether to expand belong on it: which files moved, and what failed.
|
|
1271
|
-
|
|
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) {
|
|
1272
1438
|
const stats = compactFileStats(groups.edits);
|
|
1273
1439
|
const shown = stats.slice(0, MAX_COMPACT_FILE_STATS);
|
|
1274
1440
|
const parts = shown.map(stat => `${stat.path} ${diffStatToken(stat.add, stat.del)}`.trim());
|
|
@@ -1280,7 +1446,9 @@ export class SshTui {
|
|
|
1280
1446
|
}
|
|
1281
1447
|
}
|
|
1282
1448
|
for (const line of compactFailureLines(items)) {
|
|
1283
|
-
|
|
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);
|
|
1284
1452
|
}
|
|
1285
1453
|
return;
|
|
1286
1454
|
}
|
|
@@ -2069,6 +2237,15 @@ export class SshTui {
|
|
|
2069
2237
|
onRtt: (rttMs) => {
|
|
2070
2238
|
this.applyProbedRtt(rttMs);
|
|
2071
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
|
+
},
|
|
2072
2249
|
onDetach: (info) => {
|
|
2073
2250
|
this.handleDisplayDetach(info);
|
|
2074
2251
|
},
|
|
@@ -3210,7 +3387,74 @@ export class SshTui {
|
|
|
3210
3387
|
&& this.rows.length > this.paintTailBudget + 8;
|
|
3211
3388
|
const historyStart = skipMiddle ? Math.max(0, this.rows.length - this.paintTailBudget) : 0;
|
|
3212
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);
|
|
3213
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;
|
|
3214
3458
|
if (skipMiddle && rowIndex >= 4 && rowIndex < historyStart) {
|
|
3215
3459
|
if (rowIndex === 4)
|
|
3216
3460
|
addDisplay(this.styleLine('system', t('history.folded')));
|
|
@@ -3448,6 +3692,13 @@ export class SshTui {
|
|
|
3448
3692
|
}
|
|
3449
3693
|
}
|
|
3450
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();
|
|
3451
3702
|
if (this.streaming !== undefined) {
|
|
3452
3703
|
if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
|
|
3453
3704
|
const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
@@ -3700,7 +3951,9 @@ export class SshTui {
|
|
|
3700
3951
|
const option = options[index];
|
|
3701
3952
|
if (option === undefined)
|
|
3702
3953
|
continue;
|
|
3703
|
-
|
|
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);
|
|
3704
3957
|
const key = QUESTION_OPTION_KEYS[index] ?? '↕';
|
|
3705
3958
|
const focused = index === d.cursor ? '›' : ' ';
|
|
3706
3959
|
const recommended = option.label === approve ? t('dialog.recommended') : '';
|
|
@@ -3751,7 +4004,9 @@ export class SshTui {
|
|
|
3751
4004
|
suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
|
|
3752
4005
|
}
|
|
3753
4006
|
const promptPlain = this.color ? '❯ ' : '> ';
|
|
3754
|
-
const prompt = this.color
|
|
4007
|
+
const prompt = this.color
|
|
4008
|
+
? `\x1b[${themeExtraToken(this.theme, 'accent')}m${promptPlain.trimEnd()}\x1b[0m `
|
|
4009
|
+
: promptPlain;
|
|
3755
4010
|
const promptWidth = displayWidth(promptPlain);
|
|
3756
4011
|
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
3757
4012
|
const inputTextWidth = Math.max(1, width - promptWidth);
|
|
@@ -3922,11 +4177,11 @@ export class SshTui {
|
|
|
3922
4177
|
let statusLine = this.styleLine('system', statusText);
|
|
3923
4178
|
if (this.color && this.contextPressure !== undefined) {
|
|
3924
4179
|
const ring = formatContextPressureRing(this.contextPressure.percent);
|
|
3925
|
-
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`);
|
|
3926
4181
|
}
|
|
3927
4182
|
if (this.color) {
|
|
3928
4183
|
const chip = identity.find(part => part.startsWith('sub:')) ?? '';
|
|
3929
|
-
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || '
|
|
4184
|
+
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || themeToken(this.theme, 'system'), this.colorDepth);
|
|
3930
4185
|
}
|
|
3931
4186
|
// The strip: one loss order for the groups a narrow terminal can do without.
|
|
3932
4187
|
// Health leads because it is the only group that reports a broken install;
|
|
@@ -4582,31 +4837,17 @@ export class SshTui {
|
|
|
4582
4837
|
return code === '' ? ' │ ' : `\x1b[${code}m │ `;
|
|
4583
4838
|
}
|
|
4584
4839
|
mutedSgr() {
|
|
4585
|
-
return this.color ? downgradeSgr('
|
|
4840
|
+
return this.color ? downgradeSgr(themeToken(this.theme, 'system'), this.colorDepth) : '';
|
|
4586
4841
|
}
|
|
4587
4842
|
styleLine(kind, text) {
|
|
4588
4843
|
const safe = sanitizeTerminalText(text);
|
|
4589
4844
|
if (!this.color)
|
|
4590
4845
|
return safe;
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
// Codex-like: muted add/del that blend into the terminal background.
|
|
4597
|
-
kind === 'diff-add' ? '38;2;122;168;116;48;2;18;42;24' :
|
|
4598
|
-
kind === 'diff-del' ? '38;2;196;122;122;48;2;48;20;20' :
|
|
4599
|
-
kind === 'diff-path' ? '1;36' :
|
|
4600
|
-
kind === 'todo-done' ? '2;32' :
|
|
4601
|
-
kind === 'todo-active' ? '1;36' :
|
|
4602
|
-
kind === 'todo-failed' ? '31' :
|
|
4603
|
-
kind === 'todo-skipped' ? '2;33' :
|
|
4604
|
-
kind === 'todo-pending' ? '90' :
|
|
4605
|
-
kind === 'plan-dock' ? '38;5;180' :
|
|
4606
|
-
kind === 'subagent-header' ? '38;5;141' :
|
|
4607
|
-
kind === 'diag' ? '2;36' :
|
|
4608
|
-
kind === 'error' ? '31' :
|
|
4609
|
-
'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);
|
|
4610
4851
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4611
4852
|
// Nothing left to paint: the text stands on its own.
|
|
4612
4853
|
if (code === '')
|
|
@@ -4624,7 +4865,7 @@ export class SshTui {
|
|
|
4624
4865
|
* the inverse video stays for that case.
|
|
4625
4866
|
*/
|
|
4626
4867
|
styleEmphasisedPiece(kind, text) {
|
|
4627
|
-
const requested =
|
|
4868
|
+
const requested = themeEmphasisToken(this.theme, kind);
|
|
4628
4869
|
if (requested !== undefined && this.color) {
|
|
4629
4870
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4630
4871
|
if (code !== '')
|
|
@@ -5053,11 +5294,17 @@ export class SshTui {
|
|
|
5053
5294
|
if (this.hostApprovalPolicy === 'never')
|
|
5054
5295
|
this.warnApprovalMismatch();
|
|
5055
5296
|
break;
|
|
5297
|
+
case 'user/message':
|
|
5298
|
+
this.sawUserInput = true;
|
|
5299
|
+
break;
|
|
5056
5300
|
case 'turn/start':
|
|
5057
5301
|
this.stalledWarningShown = false;
|
|
5058
5302
|
this.turnSawOutput = false;
|
|
5059
5303
|
this.turnSawReasoning = false;
|
|
5060
5304
|
this.llmRetry = undefined;
|
|
5305
|
+
// One automatic retry per user message, and only for the failure class
|
|
5306
|
+
// that is worth retrying (see `auth-failure.ts`).
|
|
5307
|
+
this.authRetryArmed = true;
|
|
5061
5308
|
this.status = `turn ${event.data.turn} running`;
|
|
5062
5309
|
// A turn is about to spend on this route: make sure the session's record
|
|
5063
5310
|
// names it, whatever changed it (a preset, a settings edit, a resume).
|
|
@@ -5094,6 +5341,7 @@ export class SshTui {
|
|
|
5094
5341
|
: `idle (${reason.kind})`;
|
|
5095
5342
|
if (reason.kind === 'error') {
|
|
5096
5343
|
this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
|
|
5344
|
+
this.reportAuthFailure(String(reason.error.message ?? ''));
|
|
5097
5345
|
}
|
|
5098
5346
|
if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
|
|
5099
5347
|
// The provider ended the turn without an answer. Some gateways map a
|
|
@@ -6614,7 +6862,7 @@ export class SshTui {
|
|
|
6614
6862
|
this.removeQueuedDialog(dialog);
|
|
6615
6863
|
dialog.resolve('cancel');
|
|
6616
6864
|
}
|
|
6617
|
-
openQuestion(question, index, total, resolve, reject, preselected, matchKeys) {
|
|
6865
|
+
openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor) {
|
|
6618
6866
|
// A list opens with its first option already chosen: Enter then answers that
|
|
6619
6867
|
// default for a single- and a multi-select question alike, instead of
|
|
6620
6868
|
// coming back with an empty selection. Esc stays the explicit cancel, and a
|
|
@@ -6623,14 +6871,26 @@ export class SshTui {
|
|
|
6623
6871
|
const initial = preselected !== undefined && preselected >= 0 && preselected < optionCount
|
|
6624
6872
|
? preselected
|
|
6625
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);
|
|
6626
6885
|
const dialog = {
|
|
6627
6886
|
kind: 'questions',
|
|
6628
6887
|
question,
|
|
6629
6888
|
index,
|
|
6630
6889
|
total,
|
|
6631
|
-
selected: new Set(
|
|
6890
|
+
selected: new Set(initialSelection),
|
|
6632
6891
|
cursor: initial >= 0 ? initial : 0,
|
|
6633
6892
|
...(matchKeys === undefined ? {} : { matchKeys }),
|
|
6893
|
+
...(onCursor === undefined ? {} : { onCursor }),
|
|
6634
6894
|
resolve: (selection) => {
|
|
6635
6895
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
6636
6896
|
},
|
|
@@ -6642,9 +6902,9 @@ export class SshTui {
|
|
|
6642
6902
|
return dialog;
|
|
6643
6903
|
}
|
|
6644
6904
|
/** Open one question dialog and await its answer (cancellation rejects). */
|
|
6645
|
-
askQuestion(question, index = 0, total = 1, preselected, matchKeys) {
|
|
6905
|
+
askQuestion(question, index = 0, total = 1, preselected, matchKeys, onCursor) {
|
|
6646
6906
|
return new Promise((resolve, reject) => {
|
|
6647
|
-
this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys);
|
|
6907
|
+
this.openQuestion(question, index, total, resolve, reject, preselected, matchKeys, onCursor);
|
|
6648
6908
|
});
|
|
6649
6909
|
}
|
|
6650
6910
|
/** The stored llm-pi-ai profile for one provider route, when settings provide one. */
|
|
@@ -8034,6 +8294,221 @@ export class SshTui {
|
|
|
8034
8294
|
this.markDirty();
|
|
8035
8295
|
}
|
|
8036
8296
|
/** /disconnect: pause (default) or continue the turn after SSH drop. */
|
|
8297
|
+
/**
|
|
8298
|
+
* Say where an auth failure came from, and retry it if the user asked for that.
|
|
8299
|
+
*
|
|
8300
|
+
* `code: "AUTH"` collapses two opposite situations (see `auth-failure.ts`), and
|
|
8301
|
+
* the host does not retry either of them: a provider-side 401/403 that lasted
|
|
8302
|
+
* 45 seconds killed two turns in a row on 2026-09-29 while the same key, model
|
|
8303
|
+
* and route answered 200 straight afterwards. The hint costs nothing and the
|
|
8304
|
+
* retry is opt-in, because resending a long session re-sends its whole context.
|
|
8305
|
+
*/
|
|
8306
|
+
reportAuthFailure(message) {
|
|
8307
|
+
if (this.replaying)
|
|
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
|
+
}
|
|
8316
|
+
const classified = classifyAuthFailure(message);
|
|
8317
|
+
if (classified === undefined)
|
|
8318
|
+
return;
|
|
8319
|
+
void this.explainAuthFailure(classified);
|
|
8320
|
+
}
|
|
8321
|
+
async explainAuthFailure(classified) {
|
|
8322
|
+
const provider = this.currentProviderId();
|
|
8323
|
+
const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
|
|
8324
|
+
const configured = (await this.resolveCredential(envRef)) !== undefined;
|
|
8325
|
+
if (classified.origin === 'provider' && configured) {
|
|
8326
|
+
this.pushRow({
|
|
8327
|
+
kind: 'system',
|
|
8328
|
+
text: t('auth.providerRejected', {
|
|
8329
|
+
env: envRef,
|
|
8330
|
+
status: classified.status === undefined ? '' : `HTTP ${classified.status}`,
|
|
8331
|
+
}),
|
|
8332
|
+
});
|
|
8333
|
+
}
|
|
8334
|
+
else {
|
|
8335
|
+
this.pushRow({ kind: 'system', text: t('auth.credentialMissing', { env: envRef }) });
|
|
8336
|
+
}
|
|
8337
|
+
const retryable = classified.origin === 'provider' && configured
|
|
8338
|
+
&& this.retryProviderAuthEnabled() && this.authRetryArmed
|
|
8339
|
+
&& this.lastUserText !== '' && this.agent.status !== 'running';
|
|
8340
|
+
if (!retryable) {
|
|
8341
|
+
this.markDirty();
|
|
8342
|
+
return;
|
|
8343
|
+
}
|
|
8344
|
+
this.authRetryArmed = false;
|
|
8345
|
+
const text = this.lastUserText;
|
|
8346
|
+
// Cleared first: the retry starts its own turn, and a second failure inside
|
|
8347
|
+
// it must not fire another attempt.
|
|
8348
|
+
this.lastUserText = '';
|
|
8349
|
+
this.pushRow({ kind: 'system', text: t('auth.retryOnce') });
|
|
8350
|
+
this.beginWait();
|
|
8351
|
+
this.agent.followup(createUserMessage({
|
|
8352
|
+
content: [{ type: 'text', text }],
|
|
8353
|
+
source: { kind: 'user' },
|
|
8354
|
+
}));
|
|
8355
|
+
this.markDirty();
|
|
8356
|
+
}
|
|
8357
|
+
/** Whether the opt-in retry is on (`/retryauth`, the settings form, or the env). */
|
|
8358
|
+
retryProviderAuthEnabled() {
|
|
8359
|
+
const raw = String(process.env.DSH_TUI_RETRY_PROVIDER_AUTH ?? '').trim().toLowerCase();
|
|
8360
|
+
if (['1', 'true', 'on', 'yes'].includes(raw))
|
|
8361
|
+
return true;
|
|
8362
|
+
if (['0', 'false', 'off', 'no'].includes(raw))
|
|
8363
|
+
return false;
|
|
8364
|
+
const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
8365
|
+
if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
8366
|
+
const value = saved.retryProviderAuth;
|
|
8367
|
+
if (typeof value === 'boolean')
|
|
8368
|
+
return value;
|
|
8369
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
8370
|
+
if (['1', 'true', 'on', 'yes'].includes(text))
|
|
8371
|
+
return true;
|
|
8372
|
+
if (['0', 'false', 'off', 'no'].includes(text))
|
|
8373
|
+
return false;
|
|
8374
|
+
}
|
|
8375
|
+
return false;
|
|
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
|
+
}
|
|
8489
|
+
/** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */
|
|
8490
|
+
async runRetryAuthCommand(arg) {
|
|
8491
|
+
const id = arg.trim().toLowerCase();
|
|
8492
|
+
if (id === '' || id === 'status') {
|
|
8493
|
+
this.pushRow({
|
|
8494
|
+
kind: 'system',
|
|
8495
|
+
text: t('retryauth.status', {
|
|
8496
|
+
state: this.retryProviderAuthEnabled() ? t('retryauth.on') : t('retryauth.off'),
|
|
8497
|
+
}),
|
|
8498
|
+
});
|
|
8499
|
+
this.markDirty();
|
|
8500
|
+
return;
|
|
8501
|
+
}
|
|
8502
|
+
if (!['on', 'off'].includes(id)) {
|
|
8503
|
+
this.pushRow({ kind: 'error', text: t('retryauth.usage') });
|
|
8504
|
+
this.markDirty();
|
|
8505
|
+
return;
|
|
8506
|
+
}
|
|
8507
|
+
const on = id === 'on';
|
|
8508
|
+
await this.mergeUiSettings({ retryProviderAuth: on });
|
|
8509
|
+
this.pushRow({ kind: 'system', text: t('retryauth.switched', { state: on ? t('retryauth.on') : t('retryauth.off') }) });
|
|
8510
|
+
this.markDirty();
|
|
8511
|
+
}
|
|
8037
8512
|
async runDisconnectCommand(arg) {
|
|
8038
8513
|
const direct = parseDisconnectPolicy(arg);
|
|
8039
8514
|
let next = direct;
|
|
@@ -10401,6 +10876,8 @@ export class SshTui {
|
|
|
10401
10876
|
this.history.push(text);
|
|
10402
10877
|
this.historyIndex = this.history.length;
|
|
10403
10878
|
this.historyDraft = '';
|
|
10879
|
+
this.sawUserInput = true;
|
|
10880
|
+
this.lastUserText = text;
|
|
10404
10881
|
this.input = '';
|
|
10405
10882
|
this.cursor = 0;
|
|
10406
10883
|
this.inputFolded = false;
|
|
@@ -10563,6 +11040,24 @@ export class SshTui {
|
|
|
10563
11040
|
this.markDirty();
|
|
10564
11041
|
});
|
|
10565
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;
|
|
11055
|
+
case 'retryauth':
|
|
11056
|
+
void this.runRetryAuthCommand(arg).catch((error) => {
|
|
11057
|
+
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'retryauth', error: errorChain(error) }) });
|
|
11058
|
+
this.markDirty();
|
|
11059
|
+
});
|
|
11060
|
+
break;
|
|
10566
11061
|
case 'copy':
|
|
10567
11062
|
this.copyFocusedCard(arg);
|
|
10568
11063
|
break;
|
|
@@ -11018,6 +11513,9 @@ export function mountTui(ctx, config) {
|
|
|
11018
11513
|
disconnectPolicy() {
|
|
11019
11514
|
return controller?.currentDisconnectPolicy() ?? 'pause';
|
|
11020
11515
|
},
|
|
11516
|
+
sessionHadUserInput() {
|
|
11517
|
+
return controller?.sessionHadUserInput() ?? false;
|
|
11518
|
+
},
|
|
11021
11519
|
};
|
|
11022
11520
|
}
|
|
11023
11521
|
//# sourceMappingURL=tui.js.map
|