dsh-ssh-tui 0.5.10 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +27 -4
- package/README.md +71 -6
- package/lib/attach.js +156 -0
- package/lib/attach.js.map +1 -0
- package/lib/commands.js +73 -0
- package/lib/commands.js.map +1 -0
- package/lib/diag.js +292 -0
- package/lib/diag.js.map +1 -0
- package/lib/dialogs.js +82 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/display-sock.js +276 -96
- package/lib/display-sock.js.map +1 -1
- package/lib/dsh-compat.js +25 -4
- package/lib/dsh-compat.js.map +1 -1
- package/lib/i18n/en.js +55 -0
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +2 -0
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +55 -0
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +75 -85
- package/lib/index.js.map +1 -1
- package/lib/paint.js +20 -56
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +152 -37
- package/lib/picker.js.map +1 -1
- package/lib/rows.js +120 -0
- package/lib/rows.js.map +1 -0
- package/lib/session-index.js +4 -1
- package/lib/session-index.js.map +1 -1
- package/lib/session-list.js +372 -167
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +20 -9
- package/lib/session-lock.js.map +1 -1
- package/lib/stats.js +136 -0
- package/lib/stats.js.map +1 -0
- package/lib/terminal-input.js +470 -0
- package/lib/terminal-input.js.map +1 -0
- package/lib/tui.js +252 -311
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +106 -0
- package/lib/types/commands.d.ts +103 -0
- package/lib/types/diag.d.ts +78 -0
- package/lib/types/dialogs.d.ts +79 -0
- package/lib/types/display-sock.d.ts +49 -3
- package/lib/types/dsh-compat.d.ts +7 -0
- package/lib/types/i18n/index.d.ts +4 -0
- package/lib/types/index.d.ts +8 -4
- package/lib/types/paint.d.ts +6 -5
- package/lib/types/picker.d.ts +27 -7
- package/lib/types/rows.d.ts +69 -0
- package/lib/types/session-list.d.ts +43 -3
- package/lib/types/stats.d.ts +98 -0
- package/lib/types/terminal-input.d.ts +164 -0
- package/lib/types/tui.d.ts +33 -8
- package/package.json +11 -5
package/lib/tui.js
CHANGED
|
@@ -30,7 +30,12 @@ import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusAr
|
|
|
30
30
|
import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
|
|
31
31
|
import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
|
|
32
32
|
import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
|
|
33
|
-
import {
|
|
33
|
+
import { collectDiag, formatDiag } from './diag.js';
|
|
34
|
+
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
35
|
+
import { QUESTION_OPTION_KEYS, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
|
|
36
|
+
import { commandSuggestions, localizedCommands } from './commands.js';
|
|
37
|
+
import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
|
|
38
|
+
import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
|
|
34
39
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
35
40
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
36
41
|
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
@@ -42,10 +47,11 @@ import { copyTextFromTranscript } from './copy-text.js';
|
|
|
42
47
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
43
48
|
import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
|
|
44
49
|
import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
|
|
50
|
+
import { TerminalInputGuard } from './terminal-input.js';
|
|
45
51
|
import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
|
|
46
52
|
import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
|
|
47
53
|
import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
|
|
48
|
-
import { buildToolHeader,
|
|
54
|
+
import { buildToolHeader, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
|
|
49
55
|
export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
|
|
50
56
|
export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
|
|
51
57
|
export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
|
|
@@ -138,16 +144,50 @@ const PLUGIN_VERSION = (() => {
|
|
|
138
144
|
})();
|
|
139
145
|
const STALL_WARNING_MS = 60000;
|
|
140
146
|
const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
|
|
147
|
+
/**
|
|
148
|
+
* How long a leftover Host that has finished its work waits, with no display,
|
|
149
|
+
* for the user to come back before it exits on its own.
|
|
150
|
+
*
|
|
151
|
+
* The Host holds the session's kernel write lock (`session.lock`) for its whole
|
|
152
|
+
* life, and that lock is exactly what makes the browser surface answer
|
|
153
|
+
* `resume failed for session "…"` (the persistence layer refuses a second
|
|
154
|
+
* writer). A Host kept alive by a busy SSH drop therefore used to block the same
|
|
155
|
+
* session in the Web UI for the full {@link DEFAULT_DETACHED_IDLE_MS} (six
|
|
156
|
+
* hours), long after the turn it stayed behind for had already finished: the
|
|
157
|
+
* session looked unreachable from both surfaces while an idle process held it.
|
|
158
|
+
* A minute is enough to notice the drop and reattach; after that the turn has
|
|
159
|
+
* long been flushed, so `--resume` reopens the same log in a fresh Host.
|
|
160
|
+
*
|
|
161
|
+
* `DSH_TUI_IDLE_EXIT_MS` (or `ssh-tui.idleExit` in settings.yaml) overrides it;
|
|
162
|
+
* `0`/`off` restores the legacy "wait for the six-hour timer" behavior.
|
|
163
|
+
*/
|
|
164
|
+
const DEFAULT_IDLE_EXIT_MS = 60 * 1000;
|
|
141
165
|
const CTRL_C_EXIT_WINDOW_MS = 2000;
|
|
142
166
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
143
|
-
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
144
167
|
const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
|
|
145
168
|
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
146
|
-
const MAX_TRANSCRIPT_ROWS = 5000;
|
|
147
169
|
const IS_WINDOWS = process.platform === 'win32';
|
|
148
170
|
function dshHomeDir() {
|
|
149
171
|
return resolveDshHome();
|
|
150
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Version of the host packages this process booted from, for `/diag`. Resolved
|
|
175
|
+
* from the running plugin's own tree, so it reports what is actually loaded
|
|
176
|
+
* rather than what is installed elsewhere.
|
|
177
|
+
*/
|
|
178
|
+
function hostDshVersion() {
|
|
179
|
+
for (const id of ['@deepseek-ai/dsh-agent/package.json', '@deepseek-ai/dsh/package.json']) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = createRequire(import.meta.url)(id);
|
|
182
|
+
if (typeof parsed.version === 'string')
|
|
183
|
+
return parsed.version;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// try the next candidate
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return 'unknown';
|
|
190
|
+
}
|
|
151
191
|
function displayDshPath(file) {
|
|
152
192
|
const home = dshHomeDir();
|
|
153
193
|
if (IS_WINDOWS) {
|
|
@@ -271,45 +311,6 @@ const DEEPSEEK_LOGO_VARIANTS = [
|
|
|
271
311
|
],
|
|
272
312
|
},
|
|
273
313
|
];
|
|
274
|
-
const LOCAL_COMMANDS = [
|
|
275
|
-
{ name: 'help', key: 'cmd.help' },
|
|
276
|
-
{ name: 'model', key: 'cmd.model' },
|
|
277
|
-
{ name: 'effort', key: 'cmd.effort' },
|
|
278
|
-
{ name: 'provider', key: 'cmd.provider' },
|
|
279
|
-
{ name: 'submodel', key: 'cmd.submodel' },
|
|
280
|
-
{ name: 'subeffort', key: 'cmd.subeffort' },
|
|
281
|
-
{ name: 'mode', key: 'cmd.mode' },
|
|
282
|
-
{ name: 'quit', key: 'cmd.quit' },
|
|
283
|
-
{ name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
|
|
284
|
-
{ name: 'clear', key: 'cmd.clear' },
|
|
285
|
-
{ name: 'status', key: 'cmd.status' },
|
|
286
|
-
{ name: 'disconnect', key: 'cmd.disconnect' },
|
|
287
|
-
{ name: 'approval', key: 'cmd.approval' },
|
|
288
|
-
{ name: 'view', key: 'cmd.view' },
|
|
289
|
-
{ name: 'usage', key: 'cmd.usage' },
|
|
290
|
-
{ name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
|
|
291
|
-
{ name: 'quota', key: 'cmd.usage', aliasOf: 'usage' },
|
|
292
|
-
{ name: 'subagents', key: 'cmd.subagents' },
|
|
293
|
-
{ name: 'resume', key: 'cmd.resume' },
|
|
294
|
-
{ name: 'setup', key: 'cmd.setup' },
|
|
295
|
-
{ name: 'find', key: 'cmd.find' },
|
|
296
|
-
{ name: 'copy', key: 'cmd.copy' },
|
|
297
|
-
{ name: 'language', key: 'cmd.language' },
|
|
298
|
-
{ name: 'lang', key: 'cmd.language', aliasOf: 'language' },
|
|
299
|
-
{ name: 'dialog-test', key: 'cmd.dialog-test' },
|
|
300
|
-
];
|
|
301
|
-
function commandDescription(name, aliasOf) {
|
|
302
|
-
if (aliasOf !== undefined)
|
|
303
|
-
return t('cmd.aliasOf', { name: aliasOf });
|
|
304
|
-
return t(`cmd.${name}`);
|
|
305
|
-
}
|
|
306
|
-
function localizedCommands() {
|
|
307
|
-
return LOCAL_COMMANDS.map(command => ({
|
|
308
|
-
name: command.name,
|
|
309
|
-
description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
|
|
310
|
-
...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
|
|
311
|
-
}));
|
|
312
|
-
}
|
|
313
314
|
export function parseDisconnectPolicy(raw) {
|
|
314
315
|
const id = raw.trim().toLowerCase();
|
|
315
316
|
if (id === 'pause' || id === 'cancel' || id === '暂停')
|
|
@@ -392,6 +393,9 @@ export class SshTui {
|
|
|
392
393
|
headlessDisplay;
|
|
393
394
|
disconnectPolicy;
|
|
394
395
|
detachedIdleTimer;
|
|
396
|
+
/** Armed once an SSH drop left this Host alive with work still running. */
|
|
397
|
+
hostKeptAlive = false;
|
|
398
|
+
idleExitTimer;
|
|
395
399
|
displayDetached = false;
|
|
396
400
|
displayHost;
|
|
397
401
|
relayColumns;
|
|
@@ -434,27 +438,18 @@ export class SshTui {
|
|
|
434
438
|
openToolCalls = new Map();
|
|
435
439
|
/** Survives result settlement so a card-less result can still be labelled. */
|
|
436
440
|
toolCallNames = new Map();
|
|
437
|
-
stats
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
ttftSteps: 0,
|
|
444
|
-
decodeMs: 0,
|
|
445
|
-
decodeTokens: 0,
|
|
446
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
447
|
-
};
|
|
448
|
-
openStepStats;
|
|
441
|
+
/** Session totals; the tracker owns the arithmetic (see `stats.ts`). */
|
|
442
|
+
statsTracker = new SessionStatsTracker();
|
|
443
|
+
/** Snapshot of the session totals, for the footer and `/status`. */
|
|
444
|
+
get stats() {
|
|
445
|
+
return this.statsTracker.snapshot();
|
|
446
|
+
}
|
|
449
447
|
/** Attempt whose live `start` frame opened the current token stream. */
|
|
450
448
|
liveStreamOwner;
|
|
451
449
|
/** Live events parked while the (yielding) history replay holds the floor. */
|
|
452
450
|
replayQueue;
|
|
453
451
|
/** A relay claimed the display while a hangup was still cancelling/flushing. */
|
|
454
452
|
reattachedDuringHangup = false;
|
|
455
|
-
pendingToolTimes = new Map();
|
|
456
|
-
usageByStep = new Map();
|
|
457
|
-
lastStatsTurn = null;
|
|
458
453
|
scrollOffset = 0;
|
|
459
454
|
clickableRows = new Map();
|
|
460
455
|
paintedLinkHitsByRow = new Map();
|
|
@@ -470,6 +465,12 @@ export class SshTui {
|
|
|
470
465
|
streamingReasoning;
|
|
471
466
|
escapeBuffer = '';
|
|
472
467
|
escapeTimer;
|
|
468
|
+
/**
|
|
469
|
+
* Cursor-position replies removed from the relay's stdin stream. A launcher
|
|
470
|
+
* from an older release (or a reply that raced its own probe) would otherwise
|
|
471
|
+
* type `[17;1R` into the prompt or cancel a dialog with a bare ESC.
|
|
472
|
+
*/
|
|
473
|
+
inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
|
|
473
474
|
thinkingStartedAt;
|
|
474
475
|
waitStartedAt;
|
|
475
476
|
completionSignaled = false;
|
|
@@ -742,6 +743,62 @@ export class SshTui {
|
|
|
742
743
|
}, idleMs);
|
|
743
744
|
this.detachedIdleTimer.unref?.();
|
|
744
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* How long a leftover, finished Host may sit with no display before it exits
|
|
748
|
+
* and hands the session back. `0` disables the exit (legacy behavior).
|
|
749
|
+
*/
|
|
750
|
+
idleExitMs() {
|
|
751
|
+
const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
|
|
752
|
+
if (Number.isFinite(raw) && raw >= 0)
|
|
753
|
+
return raw;
|
|
754
|
+
const saved = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
|
|
755
|
+
if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
756
|
+
const value = saved.idleExit;
|
|
757
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
758
|
+
return value;
|
|
759
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
760
|
+
if (text === 'off' || text === 'never' || text === 'false')
|
|
761
|
+
return 0;
|
|
762
|
+
const parsed = Number.parseInt(text, 10);
|
|
763
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
764
|
+
return parsed;
|
|
765
|
+
}
|
|
766
|
+
return DEFAULT_IDLE_EXIT_MS;
|
|
767
|
+
}
|
|
768
|
+
clearIdleExitTimer() {
|
|
769
|
+
if (this.idleExitTimer !== undefined)
|
|
770
|
+
clearTimeout(this.idleExitTimer);
|
|
771
|
+
this.idleExitTimer = undefined;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Arm the exit for a Host a busy SSH drop left behind. Called when the agent
|
|
775
|
+
* goes idle — the reason the Host was kept is gone, and every second it stays
|
|
776
|
+
* is a second its `session.lock` keeps the browser surface from opening the
|
|
777
|
+
* session (`resume failed for session "…"`). A reattach cancels it.
|
|
778
|
+
*/
|
|
779
|
+
armIdleExitTimer() {
|
|
780
|
+
if (!this.headlessDisplay || !this.hostKeptAlive)
|
|
781
|
+
return;
|
|
782
|
+
const idleMs = this.idleExitMs();
|
|
783
|
+
if (idleMs <= 0)
|
|
784
|
+
return;
|
|
785
|
+
this.clearIdleExitTimer();
|
|
786
|
+
this.idleExitTimer = setTimeout(() => {
|
|
787
|
+
this.idleExitTimer = undefined;
|
|
788
|
+
if (this.disposed || this.exiting)
|
|
789
|
+
return;
|
|
790
|
+
if (this.displayHost?.attached === true)
|
|
791
|
+
return;
|
|
792
|
+
if (this.isBusyForHangupKeepalive()) {
|
|
793
|
+
this.armIdleExitTimer();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
// Flush + exit, exactly like an idle hangup: the turn already ended, so
|
|
797
|
+
// nothing is cancelled and the lock goes back to the session store.
|
|
798
|
+
void this.requestExit(0);
|
|
799
|
+
}, idleMs);
|
|
800
|
+
this.idleExitTimer.unref?.();
|
|
801
|
+
}
|
|
745
802
|
isCompactView() {
|
|
746
803
|
return this.workspaceView === 'compact';
|
|
747
804
|
}
|
|
@@ -1030,6 +1087,7 @@ export class SshTui {
|
|
|
1030
1087
|
if (this.escapeTimer !== undefined)
|
|
1031
1088
|
clearTimeout(this.escapeTimer);
|
|
1032
1089
|
this.escapeTimer = undefined;
|
|
1090
|
+
this.inputGuard.stop();
|
|
1033
1091
|
process.stdin.removeListener('data', this.handleData);
|
|
1034
1092
|
process.stdout.removeListener('resize', this.onDirectResize);
|
|
1035
1093
|
process.stdout.removeListener('error', this.handleIoError);
|
|
@@ -1075,6 +1133,7 @@ export class SshTui {
|
|
|
1075
1133
|
this.disposed = true;
|
|
1076
1134
|
this.exiting = true;
|
|
1077
1135
|
this.clearDetachedIdleTimer();
|
|
1136
|
+
this.clearIdleExitTimer();
|
|
1078
1137
|
const dialog = this.dialog;
|
|
1079
1138
|
const queued = this.dialogQueue.splice(0);
|
|
1080
1139
|
this.dialog = undefined;
|
|
@@ -1110,6 +1169,27 @@ export class SshTui {
|
|
|
1110
1169
|
if (host !== undefined)
|
|
1111
1170
|
await host.close();
|
|
1112
1171
|
}
|
|
1172
|
+
/**
|
|
1173
|
+
* `/diag`: collect the local facts about this session's channel, lock, and
|
|
1174
|
+
* Host, then print the decision chain. Everything is local; nothing is sent
|
|
1175
|
+
* anywhere (see the privacy note in the README).
|
|
1176
|
+
*/
|
|
1177
|
+
async runDiagCommand() {
|
|
1178
|
+
const snapshot = await collectDiag({
|
|
1179
|
+
sessionId: String(this.agent.id),
|
|
1180
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1181
|
+
hostVersion: hostDshVersion(),
|
|
1182
|
+
hostProcess: isTuiHostProcess(),
|
|
1183
|
+
link: {
|
|
1184
|
+
kind: this.paintLink === 'ssh' ? 'ssh' : 'local',
|
|
1185
|
+
...(this.paintRttMs === undefined ? {} : { rttMs: this.paintRttMs }),
|
|
1186
|
+
probeState: this.paintProbed ? 'measured' : this.paintLink === 'ssh' ? 'unknown' : 'unprobed',
|
|
1187
|
+
},
|
|
1188
|
+
...(this.paintIntervalMs === undefined ? {} : { paintIntervalMs: this.paintIntervalMs }),
|
|
1189
|
+
});
|
|
1190
|
+
this.pushRow({ kind: 'system', text: formatDiag(snapshot).join('\n') });
|
|
1191
|
+
this.markDirty();
|
|
1192
|
+
}
|
|
1113
1193
|
/** Human-facing exit with goodbye and flush; called from key handling. */
|
|
1114
1194
|
async requestExit(code) {
|
|
1115
1195
|
if (this.hangingUp)
|
|
@@ -1199,6 +1279,12 @@ export class SshTui {
|
|
|
1199
1279
|
const keepHost = this.displayHost !== undefined && busy;
|
|
1200
1280
|
if (keepHost) {
|
|
1201
1281
|
this.hangingUp = false;
|
|
1282
|
+
// A relay that arrived while this hangup was unwinding set
|
|
1283
|
+
// `reattachedDuringHangup` so the hangup would honor it. That relay
|
|
1284
|
+
// *cancels* this hangup when it HELLOs, so reaching this keep-host path
|
|
1285
|
+
// means the flag is already cleared above; clearing it here too would be
|
|
1286
|
+
// dead code, and the status handler clears it on the next turn.
|
|
1287
|
+
this.hostKeptAlive = true;
|
|
1202
1288
|
this.armDetachedIdleTimer();
|
|
1203
1289
|
await this.onHangup?.();
|
|
1204
1290
|
return;
|
|
@@ -1366,7 +1452,11 @@ export class SshTui {
|
|
|
1366
1452
|
this.reattachedDuringHangup = true;
|
|
1367
1453
|
this.displayDetached = false;
|
|
1368
1454
|
this.hangingUp = false;
|
|
1455
|
+
this.hostKeptAlive = false;
|
|
1369
1456
|
this.clearDetachedIdleTimer();
|
|
1457
|
+
// The user is back: the leftover Host is a live session again, so the
|
|
1458
|
+
// idle exit must not fire out from under the display that just attached.
|
|
1459
|
+
this.clearIdleExitTimer();
|
|
1370
1460
|
this.lastActivity = Date.now();
|
|
1371
1461
|
this.stalledWarningShown = false;
|
|
1372
1462
|
this.lastPaintRows = [];
|
|
@@ -1416,52 +1506,27 @@ export class SshTui {
|
|
|
1416
1506
|
return parts.join(' · ');
|
|
1417
1507
|
}
|
|
1418
1508
|
mergeIntoToolCard(previous, next) {
|
|
1419
|
-
|
|
1420
|
-
if (!ids.includes(previous.callId))
|
|
1421
|
-
ids.push(previous.callId);
|
|
1422
|
-
if (!ids.includes(next.callId))
|
|
1423
|
-
ids.push(next.callId);
|
|
1424
|
-
previous.mergedCallIds = ids;
|
|
1425
|
-
previous.callId = next.callId;
|
|
1426
|
-
previous.name = next.name;
|
|
1427
|
-
previous.args = next.args;
|
|
1428
|
-
if (next.title !== '')
|
|
1429
|
-
previous.title = next.title;
|
|
1430
|
-
if (next.summary !== '')
|
|
1431
|
-
previous.summary = next.summary;
|
|
1432
|
-
if (next.diff !== undefined && next.diff.length > 0) {
|
|
1433
|
-
previous.diff = (previous.repeats ?? 1) > 1 && previous.diff !== undefined && previous.diff.length > 0
|
|
1434
|
-
? [...previous.diff, ...next.diff]
|
|
1435
|
-
: next.diff;
|
|
1436
|
-
}
|
|
1437
|
-
previous.status = 'running';
|
|
1438
|
-
previous.output = '';
|
|
1439
|
-
previous.exitCode = undefined;
|
|
1440
|
-
previous.signal = undefined;
|
|
1441
|
-
previous.repeats = (previous.repeats ?? 1) + 1;
|
|
1442
|
-
if (!this.replaying)
|
|
1443
|
-
previous.flipUntil = Date.now() + TOOL_FLIP_MS;
|
|
1509
|
+
mergeToolCard(previous, next, Date.now(), this.replaying);
|
|
1444
1510
|
}
|
|
1445
1511
|
findToolRowByCallId(callId) {
|
|
1446
|
-
return this.rows
|
|
1447
|
-
&& (candidate.callId === callId || candidate.mergedCallIds?.includes(callId) === true));
|
|
1512
|
+
return findToolRowByCallId(this.rows, callId);
|
|
1448
1513
|
}
|
|
1449
1514
|
findMergeableToolRow(next) {
|
|
1450
|
-
|
|
1451
|
-
return canMergeToolCall(previous, next) ? previous : undefined;
|
|
1515
|
+
return findMergeableToolRow(this.rows, next);
|
|
1452
1516
|
}
|
|
1453
1517
|
/** Append one transcript row, bounding memory on long sessions. */
|
|
1454
1518
|
pushRow(row) {
|
|
1455
1519
|
this.rows.push(row);
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1520
|
+
// Locate the focused card before trimming: after the splice every surviving
|
|
1521
|
+
// index shifts down and a stale index would clear the focus by accident.
|
|
1522
|
+
const focusedIndex = this.focusedRow === null || this.focusedRow.kind === 'streaming-reasoning'
|
|
1523
|
+
? undefined
|
|
1524
|
+
: this.rows.indexOf(this.focusedRow);
|
|
1525
|
+
const removed = boundTranscriptRows(this.rows);
|
|
1526
|
+
if (removed === 0)
|
|
1527
|
+
return;
|
|
1528
|
+
if (focusedIndex !== undefined && focusedIndex < removed)
|
|
1529
|
+
this.focusedRow = null;
|
|
1465
1530
|
}
|
|
1466
1531
|
/** The transcript rows that support per-row expand/collapse. */
|
|
1467
1532
|
collapsibleRows() {
|
|
@@ -1533,29 +1598,15 @@ export class SshTui {
|
|
|
1533
1598
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
1534
1599
|
};
|
|
1535
1600
|
}
|
|
1536
|
-
planShouldDefaultExpand(plan) {
|
|
1537
|
-
return plan.active === true
|
|
1538
|
-
|| plan.pending === true
|
|
1539
|
-
|| plan.todos.some(item => item.status === 'in_progress');
|
|
1540
|
-
}
|
|
1541
1601
|
findSubagentRow(sessionId) {
|
|
1542
1602
|
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
1543
1603
|
}
|
|
1544
1604
|
findLivePlanRow() {
|
|
1545
|
-
return this.rows
|
|
1605
|
+
return findLivePlanRow(this.rows);
|
|
1546
1606
|
}
|
|
1547
1607
|
/** Older / finished plans stay in the scrolling transcript. */
|
|
1548
1608
|
archiveStalePlans(keep) {
|
|
1549
|
-
|
|
1550
|
-
if (row.kind !== 'plan' || row === keep)
|
|
1551
|
-
continue;
|
|
1552
|
-
if (row.archived === true)
|
|
1553
|
-
continue;
|
|
1554
|
-
row.archived = true;
|
|
1555
|
-
row.active = false;
|
|
1556
|
-
row.pending = false;
|
|
1557
|
-
row.expanded = false;
|
|
1558
|
-
}
|
|
1609
|
+
archiveStalePlans(this.rows, keep);
|
|
1559
1610
|
}
|
|
1560
1611
|
upsertPlanRow(patch) {
|
|
1561
1612
|
const existing = this.findLivePlanRow();
|
|
@@ -1573,7 +1624,7 @@ export class SshTui {
|
|
|
1573
1624
|
existing.archived = true;
|
|
1574
1625
|
existing.expanded = false;
|
|
1575
1626
|
}
|
|
1576
|
-
else if (patch.expanded === undefined &&
|
|
1627
|
+
else if (patch.expanded === undefined && planShouldDefaultExpand(existing)) {
|
|
1577
1628
|
existing.expanded = true;
|
|
1578
1629
|
}
|
|
1579
1630
|
this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
|
|
@@ -1585,19 +1636,16 @@ export class SshTui {
|
|
|
1585
1636
|
existing.pending = false;
|
|
1586
1637
|
existing.expanded = false;
|
|
1587
1638
|
}
|
|
1639
|
+
const active = patch.active ?? false;
|
|
1640
|
+
const pending = patch.pending ?? false;
|
|
1641
|
+
const todos = patch.todos ?? [];
|
|
1588
1642
|
const row = {
|
|
1589
1643
|
kind: 'plan',
|
|
1590
|
-
active
|
|
1591
|
-
pending
|
|
1592
|
-
todos
|
|
1644
|
+
active,
|
|
1645
|
+
pending,
|
|
1646
|
+
todos,
|
|
1593
1647
|
...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
|
|
1594
|
-
expanded:
|
|
1595
|
-
kind: 'plan',
|
|
1596
|
-
active: patch.active ?? false,
|
|
1597
|
-
pending: patch.pending ?? false,
|
|
1598
|
-
todos: patch.todos ?? [],
|
|
1599
|
-
expanded: false,
|
|
1600
|
-
}),
|
|
1648
|
+
expanded: planShouldDefaultExpand({ active, pending, todos }),
|
|
1601
1649
|
archived: false,
|
|
1602
1650
|
};
|
|
1603
1651
|
this.pushRow(row);
|
|
@@ -2538,29 +2586,18 @@ export class SshTui {
|
|
|
2538
2586
|
const inputDivider = this.styleLine('system', repeatToWidth('─', width));
|
|
2539
2587
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
2540
2588
|
const available = Math.max(0, height - reserved - dialogLines.length);
|
|
2541
|
-
const
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
}
|
|
2554
|
-
if (this.scrollOffset > maxOffset)
|
|
2555
|
-
this.scrollOffset = maxOffset;
|
|
2556
|
-
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
2557
|
-
const visible = display.slice(start, start + available);
|
|
2558
|
-
const visibleRefs = displayRefs.slice(start, start + available);
|
|
2559
|
-
const padding = Math.max(0, available - visible.length);
|
|
2560
|
-
for (let index = 0; index < padding; index++) {
|
|
2561
|
-
visible.unshift('');
|
|
2562
|
-
visibleRefs.unshift(undefined);
|
|
2563
|
-
}
|
|
2589
|
+
const window = windowTranscript({
|
|
2590
|
+
lines: display,
|
|
2591
|
+
refs: displayRefs,
|
|
2592
|
+
available,
|
|
2593
|
+
scrollOffset: this.scrollOffset,
|
|
2594
|
+
reveal: this.pendingReveal,
|
|
2595
|
+
});
|
|
2596
|
+
this.pendingReveal = undefined;
|
|
2597
|
+
this.scrollOffset = window.scrollOffset;
|
|
2598
|
+
const start = window.start;
|
|
2599
|
+
const visible = window.visibleLines;
|
|
2600
|
+
const visibleRefs = window.visibleRefs;
|
|
2564
2601
|
this.clickableRows.clear();
|
|
2565
2602
|
this.paintedLinkHitsByRow.clear();
|
|
2566
2603
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
@@ -2578,20 +2615,7 @@ export class SshTui {
|
|
|
2578
2615
|
this.clickableRows.set(dockTop, dockPlan);
|
|
2579
2616
|
}
|
|
2580
2617
|
const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
|
|
2581
|
-
const statsGroups = footerStatsGroups(
|
|
2582
|
-
turns: this.stats.turns,
|
|
2583
|
-
steps: this.stats.steps,
|
|
2584
|
-
llmMs: this.stats.llmMs,
|
|
2585
|
-
toolMs: this.stats.toolMs,
|
|
2586
|
-
ttftMs: this.stats.ttftMs,
|
|
2587
|
-
ttftSteps: this.stats.ttftSteps,
|
|
2588
|
-
decodeMs: this.stats.decodeMs,
|
|
2589
|
-
decodeTokens: this.stats.decodeTokens,
|
|
2590
|
-
inputTokens: this.stats.usage.inputTokens,
|
|
2591
|
-
outputTokens: this.stats.usage.outputTokens,
|
|
2592
|
-
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
2593
|
-
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
2594
|
-
});
|
|
2618
|
+
const statsGroups = footerStatsGroups(statsRowOf(this.statsTracker.snapshot()));
|
|
2595
2619
|
const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
|
|
2596
2620
|
const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
|
|
2597
2621
|
const statsLine = statsPlain.startsWith(chipVisible)
|
|
@@ -2742,19 +2766,15 @@ export class SshTui {
|
|
|
2742
2766
|
this.markDirty();
|
|
2743
2767
|
}
|
|
2744
2768
|
buildSuggestions() {
|
|
2745
|
-
|
|
2746
|
-
|
|
2769
|
+
// Called on every paint: leave the host command service alone unless the
|
|
2770
|
+
// line is actually a slash command.
|
|
2771
|
+
if (!this.input.startsWith('/'))
|
|
2747
2772
|
return [];
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
.map(command => ({ name: command.name, description: command.description, local: true }));
|
|
2752
|
-
const seen = new Set(local.map(command => command.name));
|
|
2753
|
-
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
2754
|
-
.filter(command => !seen.has(command.name))
|
|
2773
|
+
// The host's commands arrive last (a local name wins the duplicate) and are
|
|
2774
|
+
// localized here, where the i18n catalog is already loaded.
|
|
2775
|
+
const foreign = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
2755
2776
|
.map(command => {
|
|
2756
|
-
const
|
|
2757
|
-
const desc = t(descKey, undefined, command.description);
|
|
2777
|
+
const desc = t(`cmd.${command.name}`, undefined, command.description);
|
|
2758
2778
|
return {
|
|
2759
2779
|
name: command.name,
|
|
2760
2780
|
description: commandAcceptsAttachments(command.input)
|
|
@@ -2763,17 +2783,7 @@ export class SshTui {
|
|
|
2763
2783
|
local: false,
|
|
2764
2784
|
};
|
|
2765
2785
|
});
|
|
2766
|
-
|
|
2767
|
-
const filtered = prefix === ''
|
|
2768
|
-
? all
|
|
2769
|
-
: all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
|
|
2770
|
-
if (prefix === '')
|
|
2771
|
-
return filtered;
|
|
2772
|
-
return filtered.sort((a, b) => {
|
|
2773
|
-
const aStart = a.name.startsWith(prefix) ? 0 : 1;
|
|
2774
|
-
const bStart = b.name.startsWith(prefix) ? 0 : 1;
|
|
2775
|
-
return aStart - bStart;
|
|
2776
|
-
});
|
|
2786
|
+
return commandSuggestions(this.input, foreign);
|
|
2777
2787
|
}
|
|
2778
2788
|
suggestionsVisible() {
|
|
2779
2789
|
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
@@ -2789,41 +2799,9 @@ export class SshTui {
|
|
|
2789
2799
|
const kind = describeProviderRoute(provider).short;
|
|
2790
2800
|
return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
|
|
2791
2801
|
}
|
|
2792
|
-
/** Replace one step's usage sample so a repeated report never double counts. */
|
|
2793
|
-
recordUsage(turn, step, usage) {
|
|
2794
|
-
const key = `${turn}:${step}`;
|
|
2795
|
-
const next = {
|
|
2796
|
-
inputTokens: usage.inputTokens,
|
|
2797
|
-
outputTokens: usage.outputTokens,
|
|
2798
|
-
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
2799
|
-
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
2800
|
-
};
|
|
2801
|
-
const previous = this.usageByStep.get(key);
|
|
2802
|
-
const totals = this.stats.usage;
|
|
2803
|
-
this.stats.usage = {
|
|
2804
|
-
inputTokens: totals.inputTokens - (previous?.inputTokens ?? 0) + next.inputTokens,
|
|
2805
|
-
outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
|
|
2806
|
-
cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
|
|
2807
|
-
cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
|
|
2808
|
-
};
|
|
2809
|
-
this.usageByStep.set(key, next);
|
|
2810
|
-
}
|
|
2811
2802
|
/** Compact session stats groups for the first footer row. */
|
|
2812
2803
|
statsText() {
|
|
2813
|
-
return footerStatsGroups(
|
|
2814
|
-
turns: this.stats.turns,
|
|
2815
|
-
steps: this.stats.steps,
|
|
2816
|
-
llmMs: this.stats.llmMs,
|
|
2817
|
-
toolMs: this.stats.toolMs,
|
|
2818
|
-
ttftMs: this.stats.ttftMs,
|
|
2819
|
-
ttftSteps: this.stats.ttftSteps,
|
|
2820
|
-
decodeMs: this.stats.decodeMs,
|
|
2821
|
-
decodeTokens: this.stats.decodeTokens,
|
|
2822
|
-
inputTokens: this.stats.usage.inputTokens,
|
|
2823
|
-
outputTokens: this.stats.usage.outputTokens,
|
|
2824
|
-
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
2825
|
-
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
2826
|
-
}).join(' │ ');
|
|
2804
|
+
return footerStatsGroups(statsRowOf(this.statsTracker.snapshot())).join(' │ ');
|
|
2827
2805
|
}
|
|
2828
2806
|
/** Refresh the terminal window title (throttled while running). */
|
|
2829
2807
|
updateTerminalTitle() {
|
|
@@ -2924,15 +2902,11 @@ export class SshTui {
|
|
|
2924
2902
|
*/
|
|
2925
2903
|
applyStreamChunk(streamed, statsOnly = false) {
|
|
2926
2904
|
const { chunk } = streamed;
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
&& open.turn === streamed.turn && open.step === streamed.step) {
|
|
2930
|
-
if (open.firstTokenTime === null && isTokenDeltaChunk(chunk)) {
|
|
2931
|
-
this.openStepStats = { ...open, firstTokenTime: streamed.time };
|
|
2932
|
-
}
|
|
2905
|
+
if (isTokenDeltaChunk(chunk)) {
|
|
2906
|
+
this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
|
|
2933
2907
|
}
|
|
2934
2908
|
if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
|
|
2935
|
-
this.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
2909
|
+
this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
2936
2910
|
}
|
|
2937
2911
|
if (statsOnly)
|
|
2938
2912
|
return;
|
|
@@ -2982,7 +2956,7 @@ export class SshTui {
|
|
|
2982
2956
|
const current = this.liveStreamOwner;
|
|
2983
2957
|
const fallback = current !== undefined && (attemptId === undefined || attemptId === current.attemptId)
|
|
2984
2958
|
? current
|
|
2985
|
-
: this.
|
|
2959
|
+
: this.statsTracker.currentStep();
|
|
2986
2960
|
const streamed = streamChunkOf(payload.frame, fallback);
|
|
2987
2961
|
if (streamed !== undefined)
|
|
2988
2962
|
this.applyStreamChunk(streamed);
|
|
@@ -3093,29 +3067,19 @@ export class SshTui {
|
|
|
3093
3067
|
.filter(block => block.type === 'reasoning')
|
|
3094
3068
|
.map(block => block.text)
|
|
3095
3069
|
.join('');
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
this.stats.ttftMs += Math.max(0, firstTokenTime - open.startTime);
|
|
3108
|
-
this.stats.ttftSteps += 1;
|
|
3109
|
-
const outputTokens = event.data.usage?.outputTokens;
|
|
3110
|
-
if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
|
|
3111
|
-
this.stats.decodeMs += Math.max(0, event.time - firstTokenTime);
|
|
3112
|
-
this.stats.decodeTokens += outputTokens;
|
|
3113
|
-
}
|
|
3114
|
-
}
|
|
3115
|
-
this.openStepStats = undefined;
|
|
3116
|
-
}
|
|
3070
|
+
// The settlement's own packed stream is authoritative: a retried step
|
|
3071
|
+
// keeps the failed attempt's first token in the live latch, and spanning
|
|
3072
|
+
// both attempts reported a rate ~20x off. 0.1.2 has no `stream` and still
|
|
3073
|
+
// relies on the replayed `assistant/chunk` events (or the live latch).
|
|
3074
|
+
this.statsTracker.settleMessage({
|
|
3075
|
+
turn: event.data.turn,
|
|
3076
|
+
step: event.data.step,
|
|
3077
|
+
time: event.time,
|
|
3078
|
+
firstTokenTime: streamFirstTokenTime(event.data.stream),
|
|
3079
|
+
outputTokens: event.data.usage?.outputTokens,
|
|
3080
|
+
});
|
|
3117
3081
|
if (event.data.usage !== undefined) {
|
|
3118
|
-
this.recordUsage(event.data.turn, event.data.step, event.data.usage);
|
|
3082
|
+
this.statsTracker.recordUsage(event.data.turn, event.data.step, event.data.usage);
|
|
3119
3083
|
}
|
|
3120
3084
|
const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
|
|
3121
3085
|
const interrupted = event.data.interrupted === true;
|
|
@@ -3138,7 +3102,7 @@ export class SshTui {
|
|
|
3138
3102
|
case 'tool/call': {
|
|
3139
3103
|
this.openToolCalls.set(String(event.data.callId), event.data.name);
|
|
3140
3104
|
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
3141
|
-
this.
|
|
3105
|
+
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
3142
3106
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
3143
3107
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
3144
3108
|
const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
|
|
@@ -3181,11 +3145,7 @@ export class SshTui {
|
|
|
3181
3145
|
}
|
|
3182
3146
|
case 'tool/result': {
|
|
3183
3147
|
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
3184
|
-
|
|
3185
|
-
if (dispatchedAt !== undefined) {
|
|
3186
|
-
this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
|
|
3187
|
-
this.pendingToolTimes.delete(String(event.data.message.source.callId));
|
|
3188
|
-
}
|
|
3148
|
+
this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
|
|
3189
3149
|
const callId = String(event.data.message.source.callId);
|
|
3190
3150
|
const row = this.findToolRowByCallId(callId);
|
|
3191
3151
|
const output = collectText(event.data.message.content);
|
|
@@ -3245,25 +3205,12 @@ export class SshTui {
|
|
|
3245
3205
|
break;
|
|
3246
3206
|
}
|
|
3247
3207
|
case 'step/start': {
|
|
3248
|
-
this.
|
|
3249
|
-
turn: event.data.turn,
|
|
3250
|
-
step: event.data.step,
|
|
3251
|
-
startTime: event.time,
|
|
3252
|
-
firstTokenTime: null,
|
|
3253
|
-
};
|
|
3208
|
+
this.statsTracker.noteStepStart(event.data.turn, event.data.step, event.time);
|
|
3254
3209
|
this.markDirty();
|
|
3255
3210
|
break;
|
|
3256
3211
|
}
|
|
3257
3212
|
case 'step/end': {
|
|
3258
|
-
|
|
3259
|
-
this.stats.turns += 1;
|
|
3260
|
-
this.lastStatsTurn = event.data.turn;
|
|
3261
|
-
}
|
|
3262
|
-
this.stats.steps += 1;
|
|
3263
|
-
this.openStepStats = undefined;
|
|
3264
|
-
// Usage accounting is complete for this step; the map only exists to
|
|
3265
|
-
// deduplicate repeated usage reports during the step.
|
|
3266
|
-
this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
|
|
3213
|
+
this.statsTracker.noteStepEnd(event.data.turn, event.data.step);
|
|
3267
3214
|
if (!this.replaying) {
|
|
3268
3215
|
this.quotaStepsSinceRefresh += 1;
|
|
3269
3216
|
const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
|
|
@@ -3295,7 +3242,7 @@ export class SshTui {
|
|
|
3295
3242
|
const reason = event.data.reason;
|
|
3296
3243
|
this.openToolCalls.clear();
|
|
3297
3244
|
this.toolCallNames.clear();
|
|
3298
|
-
this.
|
|
3245
|
+
this.statsTracker.noteTurnEnd();
|
|
3299
3246
|
this.stalledWarningShown = false;
|
|
3300
3247
|
this.pendingMessages.clear();
|
|
3301
3248
|
// Aborted/errored turns may close without an assembled
|
|
@@ -3344,6 +3291,9 @@ export class SshTui {
|
|
|
3344
3291
|
return;
|
|
3345
3292
|
this.lastActivity = Date.now();
|
|
3346
3293
|
if (status === 'running') {
|
|
3294
|
+
// Work is running again, so any reattach that raced a former hangup has
|
|
3295
|
+
// long been honored; keeping the flag would swallow the next drop.
|
|
3296
|
+
this.reattachedDuringHangup = false;
|
|
3347
3297
|
this.completionSignaled = false;
|
|
3348
3298
|
this.completedAt = 0;
|
|
3349
3299
|
if (this.waitStartedAt === undefined)
|
|
@@ -3362,6 +3312,14 @@ export class SshTui {
|
|
|
3362
3312
|
if (status !== 'running') {
|
|
3363
3313
|
this.flushPlanCloseNudge();
|
|
3364
3314
|
this.maybeIdleAutoCompact();
|
|
3315
|
+
// A leftover Host exists only because this turn was still running when
|
|
3316
|
+
// SSH dropped. Now that it settled, give the user a short window to
|
|
3317
|
+
// reattach before exiting and freeing the session's write lock for the
|
|
3318
|
+
// browser surface.
|
|
3319
|
+
this.armIdleExitTimer();
|
|
3320
|
+
}
|
|
3321
|
+
else {
|
|
3322
|
+
this.clearIdleExitTimer();
|
|
3365
3323
|
}
|
|
3366
3324
|
this.markDirty();
|
|
3367
3325
|
};
|
|
@@ -5605,7 +5563,12 @@ export class SshTui {
|
|
|
5605
5563
|
}
|
|
5606
5564
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
5607
5565
|
handleData = (chunk) => {
|
|
5608
|
-
const
|
|
5566
|
+
const decoded = this.decoder.write(chunk);
|
|
5567
|
+
if (decoded === '')
|
|
5568
|
+
return;
|
|
5569
|
+
this.inputGuard.push(decoded);
|
|
5570
|
+
};
|
|
5571
|
+
handleInputText(text) {
|
|
5609
5572
|
const combined = this.escapeBuffer + text;
|
|
5610
5573
|
this.escapeBuffer = '';
|
|
5611
5574
|
if (this.escapeTimer !== undefined) {
|
|
@@ -5796,7 +5759,7 @@ export class SshTui {
|
|
|
5796
5759
|
return;
|
|
5797
5760
|
}
|
|
5798
5761
|
this.handlePlainText(combined);
|
|
5799
|
-
}
|
|
5762
|
+
}
|
|
5800
5763
|
/** Handle one data chunk that may contain bracketed-paste markers. */
|
|
5801
5764
|
processPasteChunk(combined) {
|
|
5802
5765
|
let index = 0;
|
|
@@ -5982,14 +5945,8 @@ export class SshTui {
|
|
|
5982
5945
|
const dialog = this.dialog;
|
|
5983
5946
|
if (dialog === undefined || dialog.kind !== 'questions')
|
|
5984
5947
|
return false;
|
|
5985
|
-
|
|
5986
|
-
if (count === 0)
|
|
5948
|
+
if (!moveQuestionCursor(dialog, delta))
|
|
5987
5949
|
return false;
|
|
5988
|
-
dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
|
|
5989
|
-
if (dialog.question.multiSelect !== true) {
|
|
5990
|
-
dialog.selected.clear();
|
|
5991
|
-
dialog.selected.add(dialog.cursor);
|
|
5992
|
-
}
|
|
5993
5950
|
this.markDirty();
|
|
5994
5951
|
return true;
|
|
5995
5952
|
}
|
|
@@ -5998,9 +5955,8 @@ export class SshTui {
|
|
|
5998
5955
|
if (dialog === undefined)
|
|
5999
5956
|
return;
|
|
6000
5957
|
if (dialog.kind === 'inspect') {
|
|
6001
|
-
if (text
|
|
5958
|
+
if (inspectClosesOn(text))
|
|
6002
5959
|
this.closeInspect();
|
|
6003
|
-
}
|
|
6004
5960
|
return;
|
|
6005
5961
|
}
|
|
6006
5962
|
if (dialog.kind === 'onboarding') {
|
|
@@ -6008,45 +5964,28 @@ export class SshTui {
|
|
|
6008
5964
|
return;
|
|
6009
5965
|
}
|
|
6010
5966
|
if (dialog.kind === 'confirm') {
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
this.closeConfirm('n');
|
|
6015
|
-
else if (text === '\x03' || text === '\x1b')
|
|
6016
|
-
this.closeConfirm('cancel');
|
|
5967
|
+
const answer = confirmAnswer(text);
|
|
5968
|
+
if (answer !== undefined)
|
|
5969
|
+
this.closeConfirm(answer);
|
|
6017
5970
|
return;
|
|
6018
5971
|
}
|
|
6019
|
-
|
|
6020
|
-
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
6021
|
-
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
6022
|
-
dialog.cursor = index;
|
|
6023
|
-
if (dialog.question.multiSelect === true) {
|
|
6024
|
-
if (dialog.selected.has(index))
|
|
6025
|
-
dialog.selected.delete(index);
|
|
6026
|
-
else
|
|
6027
|
-
dialog.selected.add(index);
|
|
6028
|
-
}
|
|
6029
|
-
else {
|
|
6030
|
-
dialog.selected.clear();
|
|
6031
|
-
dialog.selected.add(index);
|
|
6032
|
-
}
|
|
5972
|
+
if (selectQuestionOptionByKey(dialog, text))
|
|
6033
5973
|
this.markDirty();
|
|
6034
|
-
}
|
|
6035
5974
|
if (text === '\r' || text === '\n') {
|
|
6036
|
-
const
|
|
6037
|
-
|
|
6038
|
-
if (selected.length === 0 && options.length > 0 && dialog.question.multiSelect !== true) {
|
|
5975
|
+
const submit = questionSubmit(dialog, this.input);
|
|
5976
|
+
if (submit.kind === 'reject') {
|
|
6039
5977
|
// no selection: treat as cancel unless there are no options
|
|
6040
5978
|
dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
6041
5979
|
return;
|
|
6042
5980
|
}
|
|
6043
|
-
if (
|
|
6044
|
-
dialog.resolve({ selected:
|
|
6045
|
-
|
|
6046
|
-
|
|
5981
|
+
if (submit.kind === 'resolve') {
|
|
5982
|
+
dialog.resolve({ selected: submit.selected, ...(submit.custom === undefined ? {} : { custom: submit.custom }) });
|
|
5983
|
+
if (submit.custom !== undefined) {
|
|
5984
|
+
this.input = '';
|
|
5985
|
+
this.cursor = 0;
|
|
5986
|
+
}
|
|
6047
5987
|
return;
|
|
6048
5988
|
}
|
|
6049
|
-
dialog.resolve({ selected });
|
|
6050
5989
|
return;
|
|
6051
5990
|
}
|
|
6052
5991
|
if (text === '\x1b' || text === '\x03') {
|
|
@@ -6915,6 +6854,12 @@ export class SshTui {
|
|
|
6915
6854
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
6916
6855
|
}
|
|
6917
6856
|
break;
|
|
6857
|
+
case 'diag':
|
|
6858
|
+
void this.runDiagCommand().catch((error) => {
|
|
6859
|
+
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command, error: errorChain(error) }) });
|
|
6860
|
+
this.markDirty();
|
|
6861
|
+
});
|
|
6862
|
+
break;
|
|
6918
6863
|
case 'usage':
|
|
6919
6864
|
case 'balance':
|
|
6920
6865
|
case 'quota':
|
|
@@ -7194,10 +7139,6 @@ export class SshTui {
|
|
|
7194
7139
|
}
|
|
7195
7140
|
}
|
|
7196
7141
|
}
|
|
7197
|
-
function optionsLength(dialog) {
|
|
7198
|
-
return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
|
|
7199
|
-
}
|
|
7200
|
-
/** Escape a string for safe interpolation into a RegExp source. */
|
|
7201
7142
|
function escapeRegex(value) {
|
|
7202
7143
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
7203
7144
|
}
|