dsh-ssh-tui 0.5.9 → 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 +28 -5
- package/README.md +76 -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 +304 -86
- 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 -73
- package/lib/index.js.map +1 -1
- package/lib/paint.js +20 -38
- 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 +257 -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 +59 -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 -3
- 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 = [];
|
|
@@ -1385,6 +1475,11 @@ export class SshTui {
|
|
|
1385
1475
|
applyProbedRtt(rttMs) {
|
|
1386
1476
|
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
1387
1477
|
this.paintLink = 'ssh';
|
|
1478
|
+
// A reattach whose probe missed its window reports "unknown"; the link has
|
|
1479
|
+
// not changed, so dropping a measurement we already have just blanks the
|
|
1480
|
+
// footer chip to four hollow circles. Keep it (the next attach re-measures).
|
|
1481
|
+
if (rttMs === undefined && this.paintProbed && this.paintRttMs !== undefined)
|
|
1482
|
+
return;
|
|
1388
1483
|
this.paintProbed = rttMs !== undefined;
|
|
1389
1484
|
this.paintRttMs = rttMs;
|
|
1390
1485
|
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
@@ -1411,52 +1506,27 @@ export class SshTui {
|
|
|
1411
1506
|
return parts.join(' · ');
|
|
1412
1507
|
}
|
|
1413
1508
|
mergeIntoToolCard(previous, next) {
|
|
1414
|
-
|
|
1415
|
-
if (!ids.includes(previous.callId))
|
|
1416
|
-
ids.push(previous.callId);
|
|
1417
|
-
if (!ids.includes(next.callId))
|
|
1418
|
-
ids.push(next.callId);
|
|
1419
|
-
previous.mergedCallIds = ids;
|
|
1420
|
-
previous.callId = next.callId;
|
|
1421
|
-
previous.name = next.name;
|
|
1422
|
-
previous.args = next.args;
|
|
1423
|
-
if (next.title !== '')
|
|
1424
|
-
previous.title = next.title;
|
|
1425
|
-
if (next.summary !== '')
|
|
1426
|
-
previous.summary = next.summary;
|
|
1427
|
-
if (next.diff !== undefined && next.diff.length > 0) {
|
|
1428
|
-
previous.diff = (previous.repeats ?? 1) > 1 && previous.diff !== undefined && previous.diff.length > 0
|
|
1429
|
-
? [...previous.diff, ...next.diff]
|
|
1430
|
-
: next.diff;
|
|
1431
|
-
}
|
|
1432
|
-
previous.status = 'running';
|
|
1433
|
-
previous.output = '';
|
|
1434
|
-
previous.exitCode = undefined;
|
|
1435
|
-
previous.signal = undefined;
|
|
1436
|
-
previous.repeats = (previous.repeats ?? 1) + 1;
|
|
1437
|
-
if (!this.replaying)
|
|
1438
|
-
previous.flipUntil = Date.now() + TOOL_FLIP_MS;
|
|
1509
|
+
mergeToolCard(previous, next, Date.now(), this.replaying);
|
|
1439
1510
|
}
|
|
1440
1511
|
findToolRowByCallId(callId) {
|
|
1441
|
-
return this.rows
|
|
1442
|
-
&& (candidate.callId === callId || candidate.mergedCallIds?.includes(callId) === true));
|
|
1512
|
+
return findToolRowByCallId(this.rows, callId);
|
|
1443
1513
|
}
|
|
1444
1514
|
findMergeableToolRow(next) {
|
|
1445
|
-
|
|
1446
|
-
return canMergeToolCall(previous, next) ? previous : undefined;
|
|
1515
|
+
return findMergeableToolRow(this.rows, next);
|
|
1447
1516
|
}
|
|
1448
1517
|
/** Append one transcript row, bounding memory on long sessions. */
|
|
1449
1518
|
pushRow(row) {
|
|
1450
1519
|
this.rows.push(row);
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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;
|
|
1460
1530
|
}
|
|
1461
1531
|
/** The transcript rows that support per-row expand/collapse. */
|
|
1462
1532
|
collapsibleRows() {
|
|
@@ -1528,29 +1598,15 @@ export class SshTui {
|
|
|
1528
1598
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
1529
1599
|
};
|
|
1530
1600
|
}
|
|
1531
|
-
planShouldDefaultExpand(plan) {
|
|
1532
|
-
return plan.active === true
|
|
1533
|
-
|| plan.pending === true
|
|
1534
|
-
|| plan.todos.some(item => item.status === 'in_progress');
|
|
1535
|
-
}
|
|
1536
1601
|
findSubagentRow(sessionId) {
|
|
1537
1602
|
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
1538
1603
|
}
|
|
1539
1604
|
findLivePlanRow() {
|
|
1540
|
-
return this.rows
|
|
1605
|
+
return findLivePlanRow(this.rows);
|
|
1541
1606
|
}
|
|
1542
1607
|
/** Older / finished plans stay in the scrolling transcript. */
|
|
1543
1608
|
archiveStalePlans(keep) {
|
|
1544
|
-
|
|
1545
|
-
if (row.kind !== 'plan' || row === keep)
|
|
1546
|
-
continue;
|
|
1547
|
-
if (row.archived === true)
|
|
1548
|
-
continue;
|
|
1549
|
-
row.archived = true;
|
|
1550
|
-
row.active = false;
|
|
1551
|
-
row.pending = false;
|
|
1552
|
-
row.expanded = false;
|
|
1553
|
-
}
|
|
1609
|
+
archiveStalePlans(this.rows, keep);
|
|
1554
1610
|
}
|
|
1555
1611
|
upsertPlanRow(patch) {
|
|
1556
1612
|
const existing = this.findLivePlanRow();
|
|
@@ -1568,7 +1624,7 @@ export class SshTui {
|
|
|
1568
1624
|
existing.archived = true;
|
|
1569
1625
|
existing.expanded = false;
|
|
1570
1626
|
}
|
|
1571
|
-
else if (patch.expanded === undefined &&
|
|
1627
|
+
else if (patch.expanded === undefined && planShouldDefaultExpand(existing)) {
|
|
1572
1628
|
existing.expanded = true;
|
|
1573
1629
|
}
|
|
1574
1630
|
this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
|
|
@@ -1580,19 +1636,16 @@ export class SshTui {
|
|
|
1580
1636
|
existing.pending = false;
|
|
1581
1637
|
existing.expanded = false;
|
|
1582
1638
|
}
|
|
1639
|
+
const active = patch.active ?? false;
|
|
1640
|
+
const pending = patch.pending ?? false;
|
|
1641
|
+
const todos = patch.todos ?? [];
|
|
1583
1642
|
const row = {
|
|
1584
1643
|
kind: 'plan',
|
|
1585
|
-
active
|
|
1586
|
-
pending
|
|
1587
|
-
todos
|
|
1644
|
+
active,
|
|
1645
|
+
pending,
|
|
1646
|
+
todos,
|
|
1588
1647
|
...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
|
|
1589
|
-
expanded:
|
|
1590
|
-
kind: 'plan',
|
|
1591
|
-
active: patch.active ?? false,
|
|
1592
|
-
pending: patch.pending ?? false,
|
|
1593
|
-
todos: patch.todos ?? [],
|
|
1594
|
-
expanded: false,
|
|
1595
|
-
}),
|
|
1648
|
+
expanded: planShouldDefaultExpand({ active, pending, todos }),
|
|
1596
1649
|
archived: false,
|
|
1597
1650
|
};
|
|
1598
1651
|
this.pushRow(row);
|
|
@@ -2533,29 +2586,18 @@ export class SshTui {
|
|
|
2533
2586
|
const inputDivider = this.styleLine('system', repeatToWidth('─', width));
|
|
2534
2587
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
2535
2588
|
const available = Math.max(0, height - reserved - dialogLines.length);
|
|
2536
|
-
const
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
}
|
|
2549
|
-
if (this.scrollOffset > maxOffset)
|
|
2550
|
-
this.scrollOffset = maxOffset;
|
|
2551
|
-
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
2552
|
-
const visible = display.slice(start, start + available);
|
|
2553
|
-
const visibleRefs = displayRefs.slice(start, start + available);
|
|
2554
|
-
const padding = Math.max(0, available - visible.length);
|
|
2555
|
-
for (let index = 0; index < padding; index++) {
|
|
2556
|
-
visible.unshift('');
|
|
2557
|
-
visibleRefs.unshift(undefined);
|
|
2558
|
-
}
|
|
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;
|
|
2559
2601
|
this.clickableRows.clear();
|
|
2560
2602
|
this.paintedLinkHitsByRow.clear();
|
|
2561
2603
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
@@ -2573,20 +2615,7 @@ export class SshTui {
|
|
|
2573
2615
|
this.clickableRows.set(dockTop, dockPlan);
|
|
2574
2616
|
}
|
|
2575
2617
|
const linkChip = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, this.color);
|
|
2576
|
-
const statsGroups = footerStatsGroups(
|
|
2577
|
-
turns: this.stats.turns,
|
|
2578
|
-
steps: this.stats.steps,
|
|
2579
|
-
llmMs: this.stats.llmMs,
|
|
2580
|
-
toolMs: this.stats.toolMs,
|
|
2581
|
-
ttftMs: this.stats.ttftMs,
|
|
2582
|
-
ttftSteps: this.stats.ttftSteps,
|
|
2583
|
-
decodeMs: this.stats.decodeMs,
|
|
2584
|
-
decodeTokens: this.stats.decodeTokens,
|
|
2585
|
-
inputTokens: this.stats.usage.inputTokens,
|
|
2586
|
-
outputTokens: this.stats.usage.outputTokens,
|
|
2587
|
-
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
2588
|
-
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
2589
|
-
});
|
|
2618
|
+
const statsGroups = footerStatsGroups(statsRowOf(this.statsTracker.snapshot()));
|
|
2590
2619
|
const statsPlain = fitFooterStatsLine(formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false), statsGroups, Math.max(1, width));
|
|
2591
2620
|
const chipVisible = formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed, false);
|
|
2592
2621
|
const statsLine = statsPlain.startsWith(chipVisible)
|
|
@@ -2737,19 +2766,15 @@ export class SshTui {
|
|
|
2737
2766
|
this.markDirty();
|
|
2738
2767
|
}
|
|
2739
2768
|
buildSuggestions() {
|
|
2740
|
-
|
|
2741
|
-
|
|
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('/'))
|
|
2742
2772
|
return [];
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
.map(command => ({ name: command.name, description: command.description, local: true }));
|
|
2747
|
-
const seen = new Set(local.map(command => command.name));
|
|
2748
|
-
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
2749
|
-
.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) ?? [])
|
|
2750
2776
|
.map(command => {
|
|
2751
|
-
const
|
|
2752
|
-
const desc = t(descKey, undefined, command.description);
|
|
2777
|
+
const desc = t(`cmd.${command.name}`, undefined, command.description);
|
|
2753
2778
|
return {
|
|
2754
2779
|
name: command.name,
|
|
2755
2780
|
description: commandAcceptsAttachments(command.input)
|
|
@@ -2758,17 +2783,7 @@ export class SshTui {
|
|
|
2758
2783
|
local: false,
|
|
2759
2784
|
};
|
|
2760
2785
|
});
|
|
2761
|
-
|
|
2762
|
-
const filtered = prefix === ''
|
|
2763
|
-
? all
|
|
2764
|
-
: all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
|
|
2765
|
-
if (prefix === '')
|
|
2766
|
-
return filtered;
|
|
2767
|
-
return filtered.sort((a, b) => {
|
|
2768
|
-
const aStart = a.name.startsWith(prefix) ? 0 : 1;
|
|
2769
|
-
const bStart = b.name.startsWith(prefix) ? 0 : 1;
|
|
2770
|
-
return aStart - bStart;
|
|
2771
|
-
});
|
|
2786
|
+
return commandSuggestions(this.input, foreign);
|
|
2772
2787
|
}
|
|
2773
2788
|
suggestionsVisible() {
|
|
2774
2789
|
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
@@ -2784,41 +2799,9 @@ export class SshTui {
|
|
|
2784
2799
|
const kind = describeProviderRoute(provider).short;
|
|
2785
2800
|
return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
|
|
2786
2801
|
}
|
|
2787
|
-
/** Replace one step's usage sample so a repeated report never double counts. */
|
|
2788
|
-
recordUsage(turn, step, usage) {
|
|
2789
|
-
const key = `${turn}:${step}`;
|
|
2790
|
-
const next = {
|
|
2791
|
-
inputTokens: usage.inputTokens,
|
|
2792
|
-
outputTokens: usage.outputTokens,
|
|
2793
|
-
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
2794
|
-
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
2795
|
-
};
|
|
2796
|
-
const previous = this.usageByStep.get(key);
|
|
2797
|
-
const totals = this.stats.usage;
|
|
2798
|
-
this.stats.usage = {
|
|
2799
|
-
inputTokens: totals.inputTokens - (previous?.inputTokens ?? 0) + next.inputTokens,
|
|
2800
|
-
outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
|
|
2801
|
-
cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
|
|
2802
|
-
cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
|
|
2803
|
-
};
|
|
2804
|
-
this.usageByStep.set(key, next);
|
|
2805
|
-
}
|
|
2806
2802
|
/** Compact session stats groups for the first footer row. */
|
|
2807
2803
|
statsText() {
|
|
2808
|
-
return footerStatsGroups(
|
|
2809
|
-
turns: this.stats.turns,
|
|
2810
|
-
steps: this.stats.steps,
|
|
2811
|
-
llmMs: this.stats.llmMs,
|
|
2812
|
-
toolMs: this.stats.toolMs,
|
|
2813
|
-
ttftMs: this.stats.ttftMs,
|
|
2814
|
-
ttftSteps: this.stats.ttftSteps,
|
|
2815
|
-
decodeMs: this.stats.decodeMs,
|
|
2816
|
-
decodeTokens: this.stats.decodeTokens,
|
|
2817
|
-
inputTokens: this.stats.usage.inputTokens,
|
|
2818
|
-
outputTokens: this.stats.usage.outputTokens,
|
|
2819
|
-
cacheReadTokens: this.stats.usage.cacheReadTokens,
|
|
2820
|
-
cacheWriteTokens: this.stats.usage.cacheWriteTokens,
|
|
2821
|
-
}).join(' │ ');
|
|
2804
|
+
return footerStatsGroups(statsRowOf(this.statsTracker.snapshot())).join(' │ ');
|
|
2822
2805
|
}
|
|
2823
2806
|
/** Refresh the terminal window title (throttled while running). */
|
|
2824
2807
|
updateTerminalTitle() {
|
|
@@ -2919,15 +2902,11 @@ export class SshTui {
|
|
|
2919
2902
|
*/
|
|
2920
2903
|
applyStreamChunk(streamed, statsOnly = false) {
|
|
2921
2904
|
const { chunk } = streamed;
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
&& open.turn === streamed.turn && open.step === streamed.step) {
|
|
2925
|
-
if (open.firstTokenTime === null && isTokenDeltaChunk(chunk)) {
|
|
2926
|
-
this.openStepStats = { ...open, firstTokenTime: streamed.time };
|
|
2927
|
-
}
|
|
2905
|
+
if (isTokenDeltaChunk(chunk)) {
|
|
2906
|
+
this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
|
|
2928
2907
|
}
|
|
2929
2908
|
if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
|
|
2930
|
-
this.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
2909
|
+
this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
2931
2910
|
}
|
|
2932
2911
|
if (statsOnly)
|
|
2933
2912
|
return;
|
|
@@ -2977,7 +2956,7 @@ export class SshTui {
|
|
|
2977
2956
|
const current = this.liveStreamOwner;
|
|
2978
2957
|
const fallback = current !== undefined && (attemptId === undefined || attemptId === current.attemptId)
|
|
2979
2958
|
? current
|
|
2980
|
-
: this.
|
|
2959
|
+
: this.statsTracker.currentStep();
|
|
2981
2960
|
const streamed = streamChunkOf(payload.frame, fallback);
|
|
2982
2961
|
if (streamed !== undefined)
|
|
2983
2962
|
this.applyStreamChunk(streamed);
|
|
@@ -3088,29 +3067,19 @@ export class SshTui {
|
|
|
3088
3067
|
.filter(block => block.type === 'reasoning')
|
|
3089
3068
|
.map(block => block.text)
|
|
3090
3069
|
.join('');
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
this.stats.ttftMs += Math.max(0, firstTokenTime - open.startTime);
|
|
3103
|
-
this.stats.ttftSteps += 1;
|
|
3104
|
-
const outputTokens = event.data.usage?.outputTokens;
|
|
3105
|
-
if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
|
|
3106
|
-
this.stats.decodeMs += Math.max(0, event.time - firstTokenTime);
|
|
3107
|
-
this.stats.decodeTokens += outputTokens;
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
this.openStepStats = undefined;
|
|
3111
|
-
}
|
|
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
|
+
});
|
|
3112
3081
|
if (event.data.usage !== undefined) {
|
|
3113
|
-
this.recordUsage(event.data.turn, event.data.step, event.data.usage);
|
|
3082
|
+
this.statsTracker.recordUsage(event.data.turn, event.data.step, event.data.usage);
|
|
3114
3083
|
}
|
|
3115
3084
|
const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
|
|
3116
3085
|
const interrupted = event.data.interrupted === true;
|
|
@@ -3133,7 +3102,7 @@ export class SshTui {
|
|
|
3133
3102
|
case 'tool/call': {
|
|
3134
3103
|
this.openToolCalls.set(String(event.data.callId), event.data.name);
|
|
3135
3104
|
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
3136
|
-
this.
|
|
3105
|
+
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
3137
3106
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
3138
3107
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
3139
3108
|
const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
|
|
@@ -3176,11 +3145,7 @@ export class SshTui {
|
|
|
3176
3145
|
}
|
|
3177
3146
|
case 'tool/result': {
|
|
3178
3147
|
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
3179
|
-
|
|
3180
|
-
if (dispatchedAt !== undefined) {
|
|
3181
|
-
this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
|
|
3182
|
-
this.pendingToolTimes.delete(String(event.data.message.source.callId));
|
|
3183
|
-
}
|
|
3148
|
+
this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
|
|
3184
3149
|
const callId = String(event.data.message.source.callId);
|
|
3185
3150
|
const row = this.findToolRowByCallId(callId);
|
|
3186
3151
|
const output = collectText(event.data.message.content);
|
|
@@ -3240,25 +3205,12 @@ export class SshTui {
|
|
|
3240
3205
|
break;
|
|
3241
3206
|
}
|
|
3242
3207
|
case 'step/start': {
|
|
3243
|
-
this.
|
|
3244
|
-
turn: event.data.turn,
|
|
3245
|
-
step: event.data.step,
|
|
3246
|
-
startTime: event.time,
|
|
3247
|
-
firstTokenTime: null,
|
|
3248
|
-
};
|
|
3208
|
+
this.statsTracker.noteStepStart(event.data.turn, event.data.step, event.time);
|
|
3249
3209
|
this.markDirty();
|
|
3250
3210
|
break;
|
|
3251
3211
|
}
|
|
3252
3212
|
case 'step/end': {
|
|
3253
|
-
|
|
3254
|
-
this.stats.turns += 1;
|
|
3255
|
-
this.lastStatsTurn = event.data.turn;
|
|
3256
|
-
}
|
|
3257
|
-
this.stats.steps += 1;
|
|
3258
|
-
this.openStepStats = undefined;
|
|
3259
|
-
// Usage accounting is complete for this step; the map only exists to
|
|
3260
|
-
// deduplicate repeated usage reports during the step.
|
|
3261
|
-
this.usageByStep.delete(`${event.data.turn}:${event.data.step}`);
|
|
3213
|
+
this.statsTracker.noteStepEnd(event.data.turn, event.data.step);
|
|
3262
3214
|
if (!this.replaying) {
|
|
3263
3215
|
this.quotaStepsSinceRefresh += 1;
|
|
3264
3216
|
const every = quotaRefreshEverySteps(this.quotaSnapshot === undefined
|
|
@@ -3290,7 +3242,7 @@ export class SshTui {
|
|
|
3290
3242
|
const reason = event.data.reason;
|
|
3291
3243
|
this.openToolCalls.clear();
|
|
3292
3244
|
this.toolCallNames.clear();
|
|
3293
|
-
this.
|
|
3245
|
+
this.statsTracker.noteTurnEnd();
|
|
3294
3246
|
this.stalledWarningShown = false;
|
|
3295
3247
|
this.pendingMessages.clear();
|
|
3296
3248
|
// Aborted/errored turns may close without an assembled
|
|
@@ -3339,6 +3291,9 @@ export class SshTui {
|
|
|
3339
3291
|
return;
|
|
3340
3292
|
this.lastActivity = Date.now();
|
|
3341
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;
|
|
3342
3297
|
this.completionSignaled = false;
|
|
3343
3298
|
this.completedAt = 0;
|
|
3344
3299
|
if (this.waitStartedAt === undefined)
|
|
@@ -3357,6 +3312,14 @@ export class SshTui {
|
|
|
3357
3312
|
if (status !== 'running') {
|
|
3358
3313
|
this.flushPlanCloseNudge();
|
|
3359
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();
|
|
3360
3323
|
}
|
|
3361
3324
|
this.markDirty();
|
|
3362
3325
|
};
|
|
@@ -5600,7 +5563,12 @@ export class SshTui {
|
|
|
5600
5563
|
}
|
|
5601
5564
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
5602
5565
|
handleData = (chunk) => {
|
|
5603
|
-
const
|
|
5566
|
+
const decoded = this.decoder.write(chunk);
|
|
5567
|
+
if (decoded === '')
|
|
5568
|
+
return;
|
|
5569
|
+
this.inputGuard.push(decoded);
|
|
5570
|
+
};
|
|
5571
|
+
handleInputText(text) {
|
|
5604
5572
|
const combined = this.escapeBuffer + text;
|
|
5605
5573
|
this.escapeBuffer = '';
|
|
5606
5574
|
if (this.escapeTimer !== undefined) {
|
|
@@ -5791,7 +5759,7 @@ export class SshTui {
|
|
|
5791
5759
|
return;
|
|
5792
5760
|
}
|
|
5793
5761
|
this.handlePlainText(combined);
|
|
5794
|
-
}
|
|
5762
|
+
}
|
|
5795
5763
|
/** Handle one data chunk that may contain bracketed-paste markers. */
|
|
5796
5764
|
processPasteChunk(combined) {
|
|
5797
5765
|
let index = 0;
|
|
@@ -5977,14 +5945,8 @@ export class SshTui {
|
|
|
5977
5945
|
const dialog = this.dialog;
|
|
5978
5946
|
if (dialog === undefined || dialog.kind !== 'questions')
|
|
5979
5947
|
return false;
|
|
5980
|
-
|
|
5981
|
-
if (count === 0)
|
|
5948
|
+
if (!moveQuestionCursor(dialog, delta))
|
|
5982
5949
|
return false;
|
|
5983
|
-
dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
|
|
5984
|
-
if (dialog.question.multiSelect !== true) {
|
|
5985
|
-
dialog.selected.clear();
|
|
5986
|
-
dialog.selected.add(dialog.cursor);
|
|
5987
|
-
}
|
|
5988
5950
|
this.markDirty();
|
|
5989
5951
|
return true;
|
|
5990
5952
|
}
|
|
@@ -5993,9 +5955,8 @@ export class SshTui {
|
|
|
5993
5955
|
if (dialog === undefined)
|
|
5994
5956
|
return;
|
|
5995
5957
|
if (dialog.kind === 'inspect') {
|
|
5996
|
-
if (text
|
|
5958
|
+
if (inspectClosesOn(text))
|
|
5997
5959
|
this.closeInspect();
|
|
5998
|
-
}
|
|
5999
5960
|
return;
|
|
6000
5961
|
}
|
|
6001
5962
|
if (dialog.kind === 'onboarding') {
|
|
@@ -6003,45 +5964,28 @@ export class SshTui {
|
|
|
6003
5964
|
return;
|
|
6004
5965
|
}
|
|
6005
5966
|
if (dialog.kind === 'confirm') {
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
this.closeConfirm('n');
|
|
6010
|
-
else if (text === '\x03' || text === '\x1b')
|
|
6011
|
-
this.closeConfirm('cancel');
|
|
5967
|
+
const answer = confirmAnswer(text);
|
|
5968
|
+
if (answer !== undefined)
|
|
5969
|
+
this.closeConfirm(answer);
|
|
6012
5970
|
return;
|
|
6013
5971
|
}
|
|
6014
|
-
|
|
6015
|
-
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
6016
|
-
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
6017
|
-
dialog.cursor = index;
|
|
6018
|
-
if (dialog.question.multiSelect === true) {
|
|
6019
|
-
if (dialog.selected.has(index))
|
|
6020
|
-
dialog.selected.delete(index);
|
|
6021
|
-
else
|
|
6022
|
-
dialog.selected.add(index);
|
|
6023
|
-
}
|
|
6024
|
-
else {
|
|
6025
|
-
dialog.selected.clear();
|
|
6026
|
-
dialog.selected.add(index);
|
|
6027
|
-
}
|
|
5972
|
+
if (selectQuestionOptionByKey(dialog, text))
|
|
6028
5973
|
this.markDirty();
|
|
6029
|
-
}
|
|
6030
5974
|
if (text === '\r' || text === '\n') {
|
|
6031
|
-
const
|
|
6032
|
-
|
|
6033
|
-
if (selected.length === 0 && options.length > 0 && dialog.question.multiSelect !== true) {
|
|
5975
|
+
const submit = questionSubmit(dialog, this.input);
|
|
5976
|
+
if (submit.kind === 'reject') {
|
|
6034
5977
|
// no selection: treat as cancel unless there are no options
|
|
6035
5978
|
dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
6036
5979
|
return;
|
|
6037
5980
|
}
|
|
6038
|
-
if (
|
|
6039
|
-
dialog.resolve({ selected:
|
|
6040
|
-
|
|
6041
|
-
|
|
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
|
+
}
|
|
6042
5987
|
return;
|
|
6043
5988
|
}
|
|
6044
|
-
dialog.resolve({ selected });
|
|
6045
5989
|
return;
|
|
6046
5990
|
}
|
|
6047
5991
|
if (text === '\x1b' || text === '\x03') {
|
|
@@ -6910,6 +6854,12 @@ export class SshTui {
|
|
|
6910
6854
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|
|
6911
6855
|
}
|
|
6912
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;
|
|
6913
6863
|
case 'usage':
|
|
6914
6864
|
case 'balance':
|
|
6915
6865
|
case 'quota':
|
|
@@ -7189,10 +7139,6 @@ export class SshTui {
|
|
|
7189
7139
|
}
|
|
7190
7140
|
}
|
|
7191
7141
|
}
|
|
7192
|
-
function optionsLength(dialog) {
|
|
7193
|
-
return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
|
|
7194
|
-
}
|
|
7195
|
-
/** Escape a string for safe interpolation into a RegExp source. */
|
|
7196
7142
|
function escapeRegex(value) {
|
|
7197
7143
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
7198
7144
|
}
|