dsh-ssh-tui 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +113 -27
- package/README.md +90 -27
- package/cordis.patch.yml +15 -0
- package/docs/remote-ops.md +27 -1
- package/docs/terminals.md +126 -0
- package/docs/windows.md +128 -0
- package/lib/attach.js +14 -1
- package/lib/attach.js.map +1 -1
- package/lib/commands.js +1 -0
- package/lib/commands.js.map +1 -1
- package/lib/copy-text.js +2 -0
- package/lib/copy-text.js.map +1 -1
- package/lib/diag.js +38 -0
- package/lib/diag.js.map +1 -1
- package/lib/dialogs.js.map +1 -1
- package/lib/display-sock.js +572 -44
- package/lib/display-sock.js.map +1 -1
- package/lib/doctor.js +71 -37
- package/lib/doctor.js.map +1 -1
- package/lib/dsh-compat.js +252 -88
- package/lib/dsh-compat.js.map +1 -1
- package/lib/footer.js +4 -2
- package/lib/footer.js.map +1 -1
- package/lib/i18n/en.js +54 -10
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +18 -7
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +54 -10
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +111 -26
- package/lib/index.js.map +1 -1
- package/lib/line-mode.js +4 -0
- package/lib/line-mode.js.map +1 -1
- package/lib/paint.js +37 -3
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +109 -18
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +8 -0
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +377 -11
- package/lib/platform.js.map +1 -1
- package/lib/preset-authoring.js +10 -14
- package/lib/preset-authoring.js.map +1 -1
- package/lib/preset-compat.js +100 -0
- package/lib/preset-compat.js.map +1 -0
- package/lib/preset-picker.js +5 -1
- package/lib/preset-picker.js.map +1 -1
- package/lib/preset-rows.js +119 -19
- package/lib/preset-rows.js.map +1 -1
- package/lib/provider-catalog.js +4 -4
- package/lib/question-wait.js +418 -0
- package/lib/question-wait.js.map +1 -0
- package/lib/route-memory.js +3 -3
- package/lib/route-memory.js.map +1 -1
- package/lib/selection.js +26 -8
- package/lib/selection.js.map +1 -1
- package/lib/session-index.js +5 -0
- package/lib/session-index.js.map +1 -1
- package/lib/session-lock.js +4 -1
- package/lib/session-lock.js.map +1 -1
- package/lib/session-route.js +3 -0
- package/lib/session-route.js.map +1 -1
- package/lib/settings-routes.js +10 -0
- package/lib/settings-routes.js.map +1 -0
- package/lib/settings-subagent.js +10 -0
- package/lib/settings-subagent.js.map +1 -0
- package/lib/subagent-model.js +5 -5
- package/lib/subagent-model.js.map +1 -1
- package/lib/supergrok-token.js +4 -0
- package/lib/supergrok-token.js.map +1 -1
- package/lib/term-text.js +79 -8
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-caps.js +358 -0
- package/lib/terminal-caps.js.map +1 -0
- package/lib/terminal-input.js +13 -0
- package/lib/terminal-input.js.map +1 -1
- package/lib/tui.js +775 -165
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +8 -0
- package/lib/types/commands.d.ts +3 -0
- package/lib/types/diag.d.ts +20 -1
- package/lib/types/dialogs.d.ts +16 -0
- package/lib/types/display-sock.d.ts +141 -24
- package/lib/types/doctor.d.ts +8 -1
- package/lib/types/dsh-compat.d.ts +173 -44
- package/lib/types/footer.d.ts +3 -1
- package/lib/types/i18n/index.d.ts +31 -15
- package/lib/types/index.d.ts +45 -0
- package/lib/types/paint.d.ts +21 -0
- package/lib/types/picker.d.ts +25 -0
- package/lib/types/platform.d.ts +197 -10
- package/lib/types/preset-authoring.d.ts +8 -14
- package/lib/types/preset-compat.d.ts +58 -0
- package/lib/types/preset-picker.d.ts +1 -1
- package/lib/types/preset-rows.d.ts +63 -13
- package/lib/types/question-wait.d.ts +221 -0
- package/lib/types/selection.d.ts +12 -4
- package/lib/types/settings-routes.d.ts +16 -0
- package/lib/types/settings-subagent.d.ts +24 -0
- package/lib/types/subagent-model.d.ts +10 -10
- package/lib/types/term-text.d.ts +14 -0
- package/lib/types/terminal-caps.d.ts +105 -0
- package/lib/types/terminal-input.d.ts +10 -0
- package/lib/types/transcript-types.d.ts +22 -0
- package/lib/types/tui.d.ts +167 -10
- package/lib/types/update-check.d.ts +19 -4
- package/lib/types/workspace-changes.d.ts +135 -0
- package/lib/update-check.js +25 -8
- package/lib/update-check.js.map +1 -1
- package/lib/workspace-changes.js +120 -0
- package/lib/workspace-changes.js.map +1 -0
- package/package.json +124 -59
package/lib/tui.js
CHANGED
|
@@ -22,11 +22,11 @@ import { homedir } from 'node:os';
|
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
24
|
import { StringDecoder } from 'node:string_decoder';
|
|
25
|
-
import { METADATA_FILE, readPresetMetadata } from '
|
|
25
|
+
import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
|
|
26
26
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
27
27
|
import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
28
28
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
29
|
-
import { commandAcceptsAttachments, forEachSessionEventAsync,
|
|
29
|
+
import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, TUI_SOURCE_KIND, } from './dsh-compat.js';
|
|
30
30
|
import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
|
|
31
31
|
import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
|
|
32
32
|
import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
|
|
@@ -41,27 +41,30 @@ import { keymapReport, resolveKeymap } from './keymap.js';
|
|
|
41
41
|
import { presetLabel, profileFromArgv } from './preset-label.js';
|
|
42
42
|
import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
43
43
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
44
|
-
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup,
|
|
44
|
+
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
|
|
45
45
|
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
46
46
|
import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
|
|
47
47
|
import { commandSuggestions, localizedCommands } from './commands.js';
|
|
48
48
|
import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
|
|
49
49
|
import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
|
|
50
|
+
import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
|
|
51
|
+
import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
|
|
50
52
|
import { sameSessionRoute, sessionRouteInput } from './session-route.js';
|
|
51
53
|
import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
|
|
52
54
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
53
55
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
54
56
|
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
55
57
|
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
56
|
-
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
|
|
57
58
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentProviderDiffers, subagentSettingsValue, } from './subagent-model.js';
|
|
58
59
|
import { resolveFreshSuperGrokToken } from './supergrok-token.js';
|
|
59
60
|
import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
|
|
60
61
|
import { displayToolName, subagentCourtesyName } from './job-label.js';
|
|
62
|
+
import { clearWaitingMarker, notifyCommand, notifyTargetCommand, notifyTargetLabel, parseNotifyTarget, runNotify, writeWaitingMarker, } from './question-wait.js';
|
|
63
|
+
import { changesFileLine, changesHeader, changesRemainderLine, changesSummaryVisible, renderChangesDiff, workspaceChangesOf, } from './workspace-changes.js';
|
|
61
64
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
62
65
|
import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, highlightAnsiNeedle, searchContains, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, stripAnsi, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
|
|
63
66
|
import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
|
|
64
|
-
import { captureHangupSignals, advancePaintedRows, composePaintFrame, composePaintOutput, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, } from './paint.js';
|
|
67
|
+
import { captureHangupSignals, advancePaintedRows, composePaintFrame, composePaintOutput, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
|
|
65
68
|
import { TerminalInputGuard } from './terminal-input.js';
|
|
66
69
|
import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerSubagentForeign, paintFooterSubagentChip, footerStatsGroups, formatContextPressureChip, providerHasQuotaSurface, formatStatusReport, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
|
|
67
70
|
import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
|
|
@@ -71,11 +74,11 @@ export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hre
|
|
|
71
74
|
export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
|
|
72
75
|
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, footerSubagentForeign, paintFooterSubagentChip, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, providerHasQuotaSurface, quotaWindowTag, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
|
|
73
76
|
export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
|
|
74
|
-
export { commandAcceptsAttachments, forEachSessionEvent,
|
|
77
|
+
export { commandAcceptsAttachments, forEachSessionEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
|
|
75
78
|
export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, buildSubagentHeader, clipSubagentActivity, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, foldSubagentUserLog, todoProgressLabel, todoSummary, } from './plan.js';
|
|
76
79
|
export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js';
|
|
77
80
|
function discoverProviderModels(llm, request, signal) {
|
|
78
|
-
return llm.discoverModels(settingsNamespace('llm-pi-ai'),
|
|
81
|
+
return llm.discoverModels(settingsNamespace('llm-pi-ai'), request, signal);
|
|
79
82
|
}
|
|
80
83
|
/**
|
|
81
84
|
* Ask a gateway which routes each of its models answers (`supported_endpoints`
|
|
@@ -111,11 +114,7 @@ async function fetchModelEndpoints(baseURL, apiKey, signal) {
|
|
|
111
114
|
return undefined;
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
|
-
function installUserQuestionAnswerer(ctx,
|
|
115
|
-
const provider = questions;
|
|
116
|
-
if (typeof provider.registerProvider === 'function') {
|
|
117
|
-
return provider.registerProvider({ ask });
|
|
118
|
-
}
|
|
117
|
+
function installUserQuestionAnswerer(ctx, ask) {
|
|
119
118
|
return ctx.on('user-questions/request', async (request, next) => {
|
|
120
119
|
try {
|
|
121
120
|
return await ask(request);
|
|
@@ -128,6 +127,16 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
|
|
|
128
127
|
});
|
|
129
128
|
}
|
|
130
129
|
const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
|
|
130
|
+
/**
|
|
131
|
+
* The `agent-default-model` settings entry `/model` falls back to writing.
|
|
132
|
+
*
|
|
133
|
+
* 0.1.5 exports this id as `AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE`; 0.1.7
|
|
134
|
+
* removed the export and let the profile own entry ids, which keeps the name the
|
|
135
|
+
* legacy `settings.yaml` section is imported under. Inlined so one build runs
|
|
136
|
+
* against either host — the primary path, `agentDefaultModel.saveSelection`,
|
|
137
|
+
* is the same call on both.
|
|
138
|
+
*/
|
|
139
|
+
const AGENT_DEFAULT_MODEL_NS = settingsNamespace('agent-default-model');
|
|
131
140
|
/**
|
|
132
141
|
* The window llm-pi-ai falls back to when neither a model entry nor the
|
|
133
142
|
* installed catalog sizes a model. The wizard pre-fills it only so a route
|
|
@@ -308,7 +317,6 @@ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
|
|
|
308
317
|
*/
|
|
309
318
|
const QUOTA_RETRY_MS = 15_000;
|
|
310
319
|
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
311
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
312
320
|
function dshHomeDir() {
|
|
313
321
|
return resolveDshHome();
|
|
314
322
|
}
|
|
@@ -331,23 +339,9 @@ function hostDshVersion() {
|
|
|
331
339
|
return 'unknown';
|
|
332
340
|
}
|
|
333
341
|
function displayDshPath(file) {
|
|
334
|
-
|
|
335
|
-
if (IS_WINDOWS) {
|
|
336
|
-
const profile = process.env.USERPROFILE;
|
|
337
|
-
if (profile !== undefined && home.toLowerCase().startsWith(profile.toLowerCase())) {
|
|
338
|
-
const rest = home.slice(profile.length);
|
|
339
|
-
return `%USERPROFILE%${rest}\\${file}`.replaceAll('/', '\\');
|
|
340
|
-
}
|
|
341
|
-
return `${home}\\${file}`.replaceAll('/', '\\');
|
|
342
|
-
}
|
|
343
|
-
const userHome = homedir();
|
|
344
|
-
if (home === userHome)
|
|
345
|
-
return `~/.dsh/${file}`;
|
|
346
|
-
if (home.startsWith(`${userHome}/`))
|
|
347
|
-
return `~/${home.slice(userHome.length + 1)}/${file}`;
|
|
348
|
-
return join(home, file);
|
|
342
|
+
return displayHomePath(dshHomeDir(), file);
|
|
349
343
|
}
|
|
350
|
-
const DSH_ENV_FILE = join(dshHomeDir(),
|
|
344
|
+
const DSH_ENV_FILE = join(dshHomeDir(), envFileName());
|
|
351
345
|
const DEEPSEEK_LOGO_VARIANTS = [
|
|
352
346
|
{
|
|
353
347
|
width: 52,
|
|
@@ -453,6 +447,25 @@ const DEEPSEEK_LOGO_VARIANTS = [
|
|
|
453
447
|
],
|
|
454
448
|
},
|
|
455
449
|
];
|
|
450
|
+
/**
|
|
451
|
+
* What marks the row the card cursor is on.
|
|
452
|
+
*
|
|
453
|
+
* The card headers spell `▶ ` themselves; a reply is drawn by the markdown
|
|
454
|
+
* renderer, so its marker is prepended and its width is measured from this
|
|
455
|
+
* string (three cells, or two once the ASCII fallback has rewritten it).
|
|
456
|
+
*/
|
|
457
|
+
const REPLY_FOCUS_MARKER = '▶ ';
|
|
458
|
+
/**
|
|
459
|
+
* The text an inspect overlay hands to the copy key: what the overlay shows.
|
|
460
|
+
*
|
|
461
|
+
* The overlays are the one place the copy key can be pressed without leaving
|
|
462
|
+
* them (a dialog covers the input line), so the body on screen is what "copy"
|
|
463
|
+
* means there — not the card row that opened it, which for a changes card is a
|
|
464
|
+
* file list and for a tool card a summary the reader has already scrolled past.
|
|
465
|
+
*/
|
|
466
|
+
function inspectCopyText(lines) {
|
|
467
|
+
return lines.map(line => line.text).join('\n').replace(/\s+$/u, '');
|
|
468
|
+
}
|
|
456
469
|
export function parseDisconnectPolicy(raw) {
|
|
457
470
|
const id = raw.trim().toLowerCase();
|
|
458
471
|
if (id === 'pause' || id === 'cancel' || id === '暂停')
|
|
@@ -634,6 +647,7 @@ export class SshTui {
|
|
|
634
647
|
relayRows;
|
|
635
648
|
onHangup;
|
|
636
649
|
onReattach;
|
|
650
|
+
onDetach;
|
|
637
651
|
renderTimer;
|
|
638
652
|
decoder = new StringDecoder('utf8');
|
|
639
653
|
color;
|
|
@@ -654,6 +668,14 @@ export class SshTui {
|
|
|
654
668
|
/** Last route reported to the launcher; a repaint must not report it again. */
|
|
655
669
|
lastSessionRoute;
|
|
656
670
|
useAlternateScreen;
|
|
671
|
+
/**
|
|
672
|
+
* What the terminal in front of us can do. Read from the environment at
|
|
673
|
+
* construction (see `terminal-caps.ts`): the mouse, bracketed paste and the
|
|
674
|
+
* alternate screen are only claimed where the terminal has them.
|
|
675
|
+
*/
|
|
676
|
+
terminalCaps;
|
|
677
|
+
/** The OSC 52 caveat is worth saying once, not after every copy. */
|
|
678
|
+
osc52HintShown = false;
|
|
657
679
|
agentGone = false;
|
|
658
680
|
onboarding;
|
|
659
681
|
commandSuggestions = [];
|
|
@@ -728,6 +750,15 @@ export class SshTui {
|
|
|
728
750
|
inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
|
|
729
751
|
thinkingStartedAt;
|
|
730
752
|
waitStartedAt;
|
|
753
|
+
/**
|
|
754
|
+
* Whether the running turn produced anything a person can read: reply text or
|
|
755
|
+
* a tool card. A turn that ends with thinking only is the upstream returning
|
|
756
|
+
* an empty stop, and painting 完成 for it reads as a finished answer — see
|
|
757
|
+
* the hint at `turn/end`.
|
|
758
|
+
*/
|
|
759
|
+
turnSawOutput = false;
|
|
760
|
+
/** Whether the turn produced thinking, which the empty-stop hint names. */
|
|
761
|
+
turnSawReasoning = false;
|
|
731
762
|
completionSignaled = false;
|
|
732
763
|
replaying = false;
|
|
733
764
|
/** Display-line budget for the first paint after resume; 0 = full transcript. */
|
|
@@ -781,10 +812,22 @@ export class SshTui {
|
|
|
781
812
|
detachedDeniedCount = 0;
|
|
782
813
|
/** Questions waiting for a display right now — their cards do not exist yet. */
|
|
783
814
|
queuedQuestions = 0;
|
|
815
|
+
/** When the current detached wait began, so the reconnect can say how long. */
|
|
816
|
+
questionWaitSince;
|
|
784
817
|
paintIntervalMs;
|
|
785
818
|
paintLink = 'local';
|
|
819
|
+
/**
|
|
820
|
+
* Whether this process runs inside an SSH session, from the sshd environment.
|
|
821
|
+
*
|
|
822
|
+
* Distinct from `paintLink`, which `applyProbedRtt` pins to `'ssh'` for any
|
|
823
|
+
* relayed frame — a local window included. Only this flag says the capability
|
|
824
|
+
* table described a remote tty rather than the terminal the bytes reach.
|
|
825
|
+
*/
|
|
826
|
+
sshSession = false;
|
|
786
827
|
paintProbed = false;
|
|
787
828
|
paintRttMs;
|
|
829
|
+
/** The last few measured round-trips; the chip and the budget use their median. */
|
|
830
|
+
paintRttHistory = [];
|
|
788
831
|
sessionTitle = '';
|
|
789
832
|
llmRetry;
|
|
790
833
|
quotaSnapshot;
|
|
@@ -833,6 +876,7 @@ export class SshTui {
|
|
|
833
876
|
this.onRouteSettled = config.onRouteSettled;
|
|
834
877
|
this.onHangup = config.onHangup;
|
|
835
878
|
this.onReattach = config.onReattach;
|
|
879
|
+
this.onDetach = config.onDetach;
|
|
836
880
|
this.headlessDisplay = config.headlessDisplay === true;
|
|
837
881
|
// Line mode: no frames at all, one appended line per event. The alternate
|
|
838
882
|
// screen is what makes a screen reader and a `tee` lose events, so it stays
|
|
@@ -842,9 +886,15 @@ export class SshTui {
|
|
|
842
886
|
this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
|
|
843
887
|
this.presetId = config.presetId ?? 'standard';
|
|
844
888
|
this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
|
|
845
|
-
this.
|
|
846
|
-
|
|
847
|
-
this
|
|
889
|
+
this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
|
|
890
|
+
this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
|
|
891
|
+
// Whether this process is inside an SSH session, kept apart from
|
|
892
|
+
// `paintLink`: that one also becomes `'ssh'` whenever the launcher relayed
|
|
893
|
+
// the frame (see `applyProbedRtt`), which is true for a purely local window
|
|
894
|
+
// too. This field answers the narrower question the clipboard caveat needs —
|
|
895
|
+
// "is the terminal the capability table described the one the bytes reach".
|
|
896
|
+
this.sshSession = detectSshSession();
|
|
897
|
+
this.paintLink = this.sshSession ? 'ssh' : 'local';
|
|
848
898
|
this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
|
|
849
899
|
ssh: this.paintLink === 'ssh',
|
|
850
900
|
});
|
|
@@ -861,7 +911,13 @@ export class SshTui {
|
|
|
861
911
|
}
|
|
862
912
|
this.refreshRosterHealth();
|
|
863
913
|
if (this.rosterMissing) {
|
|
864
|
-
|
|
914
|
+
// The missing thing differs by host line: a roster the profile can mount,
|
|
915
|
+
// or the agent-plane rows a 0.1.7 terminal profile owns. The advice has to
|
|
916
|
+
// match what `/mode fix` will actually write.
|
|
917
|
+
this.pushRow({
|
|
918
|
+
kind: 'system',
|
|
919
|
+
text: t(this.settingsGeneration === 'forms' ? 'mode.bootFormsMissing' : 'mode.bootMissing'),
|
|
920
|
+
});
|
|
865
921
|
}
|
|
866
922
|
if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
|
|
867
923
|
this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
|
|
@@ -869,6 +925,14 @@ export class SshTui {
|
|
|
869
925
|
if (config.restoredRoute !== undefined) {
|
|
870
926
|
this.pushRow({ kind: 'system', text: sessionRouteNotice(config.restoredRoute) });
|
|
871
927
|
}
|
|
928
|
+
// Windows only, and only on the fallback path: with no PowerShell there is
|
|
929
|
+
// no hidden console, so the Host is a direct child and closing the window
|
|
930
|
+
// ends the session's compute. Say it at boot rather than let the user find
|
|
931
|
+
// out by closing the window mid-turn. A bootstrapped Windows Host and every
|
|
932
|
+
// POSIX Host keep running, so neither says anything.
|
|
933
|
+
if (isTuiHostProcess() && !hostHasOwnConsole()) {
|
|
934
|
+
this.pushRow({ kind: 'system', text: t('boot.directHost') });
|
|
935
|
+
}
|
|
872
936
|
for (const notice of config.launchNotices ?? []) {
|
|
873
937
|
this.pushRow({ kind: notice.kind, text: notice.text });
|
|
874
938
|
}
|
|
@@ -898,7 +962,7 @@ export class SshTui {
|
|
|
898
962
|
process.stdin.setRawMode(true);
|
|
899
963
|
process.stdin.resume();
|
|
900
964
|
process.stdout.on('resize', this.onDirectResize);
|
|
901
|
-
if (
|
|
965
|
+
if (usesSigwinch()) {
|
|
902
966
|
process.on('SIGWINCH', this.onDirectResize);
|
|
903
967
|
}
|
|
904
968
|
process.stdin.prependListener('end', this.handleHangupStream);
|
|
@@ -912,7 +976,7 @@ export class SshTui {
|
|
|
912
976
|
this.bootBackgroundTasks();
|
|
913
977
|
return;
|
|
914
978
|
}
|
|
915
|
-
this.write(
|
|
979
|
+
this.write(this.enterScreenSequence());
|
|
916
980
|
this.render();
|
|
917
981
|
this.updateTerminalTitle();
|
|
918
982
|
void this.calibratePaintInterval().finally(() => {
|
|
@@ -923,11 +987,22 @@ export class SshTui {
|
|
|
923
987
|
});
|
|
924
988
|
this.bootBackgroundTasks();
|
|
925
989
|
}
|
|
990
|
+
/**
|
|
991
|
+
* Take over the screen: alternate screen where it is safe, then the input
|
|
992
|
+
* modes the terminal actually supports, then hide the cursor.
|
|
993
|
+
*
|
|
994
|
+
* Joining the parts here (rather than at each call site) is what keeps
|
|
995
|
+
* `start`, a reattach and a teardown from drifting apart — the reattach path
|
|
996
|
+
* used to duplicate this string verbatim.
|
|
997
|
+
*/
|
|
998
|
+
enterScreenSequence() {
|
|
999
|
+
return `${this.useAlternateScreen ? '\x1b[?1049h' : ''}${mouseEnableSequence(this.terminalCaps)}${bracketedPasteSequence(this.terminalCaps, true)}\x1b[?25l`;
|
|
1000
|
+
}
|
|
926
1001
|
bindAgentEvents() {
|
|
927
1002
|
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), listenHostEvent(this.ctx, 'agent/assistant-stream', this.handleAssistantStream), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
928
1003
|
const questions = this.ctx.get('userQuestions');
|
|
929
1004
|
if (questions !== undefined) {
|
|
930
|
-
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx,
|
|
1005
|
+
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, this.handleUserQuestions);
|
|
931
1006
|
}
|
|
932
1007
|
}
|
|
933
1008
|
bootBackgroundTasks() {
|
|
@@ -988,7 +1063,7 @@ export class SshTui {
|
|
|
988
1063
|
return;
|
|
989
1064
|
this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
|
|
990
1065
|
this.markDirty();
|
|
991
|
-
const result = await installPluginLatest(info.profile);
|
|
1066
|
+
const result = await installPluginLatest(info.profile, info.latest);
|
|
992
1067
|
if (this.disposed)
|
|
993
1068
|
return;
|
|
994
1069
|
if (result.ok) {
|
|
@@ -1008,7 +1083,7 @@ export class SshTui {
|
|
|
1008
1083
|
}
|
|
1009
1084
|
}
|
|
1010
1085
|
readSkippedUpdate() {
|
|
1011
|
-
const raw = this.ctx
|
|
1086
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1012
1087
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1013
1088
|
return undefined;
|
|
1014
1089
|
const skip = raw.skipUpdate;
|
|
@@ -1021,14 +1096,14 @@ export class SshTui {
|
|
|
1021
1096
|
const settings = this.ctx.get('settings');
|
|
1022
1097
|
if (settings === undefined)
|
|
1023
1098
|
return;
|
|
1024
|
-
const raw =
|
|
1099
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1025
1100
|
const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
1026
1101
|
? raw
|
|
1027
1102
|
: {};
|
|
1028
1103
|
await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
|
|
1029
1104
|
}
|
|
1030
1105
|
readWorkspaceView() {
|
|
1031
|
-
const raw = this.ctx
|
|
1106
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1032
1107
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1033
1108
|
return 'detailed';
|
|
1034
1109
|
return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
|
|
@@ -1037,7 +1112,7 @@ export class SshTui {
|
|
|
1037
1112
|
const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
|
|
1038
1113
|
if (env !== undefined)
|
|
1039
1114
|
return env;
|
|
1040
|
-
const raw = this.ctx
|
|
1115
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1041
1116
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1042
1117
|
return 'pause';
|
|
1043
1118
|
return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
|
|
@@ -1046,7 +1121,7 @@ export class SshTui {
|
|
|
1046
1121
|
const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
|
|
1047
1122
|
if (env !== undefined)
|
|
1048
1123
|
return env;
|
|
1049
|
-
const raw = this.ctx
|
|
1124
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1050
1125
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1051
1126
|
return 'off';
|
|
1052
1127
|
return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
|
|
@@ -1086,7 +1161,7 @@ export class SshTui {
|
|
|
1086
1161
|
const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
|
|
1087
1162
|
if (Number.isFinite(raw) && raw >= 0)
|
|
1088
1163
|
return raw;
|
|
1089
|
-
const saved = this.ctx
|
|
1164
|
+
const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
1090
1165
|
if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
1091
1166
|
const value = saved.idleExit;
|
|
1092
1167
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
@@ -1510,7 +1585,11 @@ export class SshTui {
|
|
|
1510
1585
|
try {
|
|
1511
1586
|
process.stdout.write('\x1b]0;\x07');
|
|
1512
1587
|
process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
1513
|
-
|
|
1588
|
+
// Leave the alternate screen unconditionally, the way every mouse mode is
|
|
1589
|
+
// disabled: `?1049l` on a terminal that never entered is ignored, while
|
|
1590
|
+
// *skipping* it after a reattach whose relay classified the terminal
|
|
1591
|
+
// differently leaves the user stuck in a screen they cannot scroll.
|
|
1592
|
+
process.stdout.write(`${mouseDisableSequence()}${bracketedPasteSequence(this.terminalCaps, false)}\x1b[?25h\x1b[?1049l`);
|
|
1514
1593
|
}
|
|
1515
1594
|
catch (error) {
|
|
1516
1595
|
if (!isHangupErrno(error)) {
|
|
@@ -1611,10 +1690,7 @@ export class SshTui {
|
|
|
1611
1690
|
}
|
|
1612
1691
|
/** Routing facts for the doctor's self-consistency check. */
|
|
1613
1692
|
doctorRouting() {
|
|
1614
|
-
const
|
|
1615
|
-
if (settings === undefined)
|
|
1616
|
-
return undefined;
|
|
1617
|
-
const section = settings.get(settingsNamespace('llm-pi-ai'));
|
|
1693
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
1618
1694
|
const providers = section?.providers ?? {};
|
|
1619
1695
|
const provider = this.currentProvider();
|
|
1620
1696
|
const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
|
|
@@ -1640,6 +1716,7 @@ export class SshTui {
|
|
|
1640
1716
|
roster: this.ctx.get('agentPresets') !== undefined,
|
|
1641
1717
|
codeRuntime: this.hasHostService('codeRuntime'),
|
|
1642
1718
|
},
|
|
1719
|
+
generation: hostSettingsGeneration(this.ctx),
|
|
1643
1720
|
anchors: [process.argv[1], this.ctx.baseUrl],
|
|
1644
1721
|
...(routing === undefined ? {} : { routing }),
|
|
1645
1722
|
});
|
|
@@ -1660,11 +1737,11 @@ export class SshTui {
|
|
|
1660
1737
|
/** `/fix <row>`: repair one named roster row of the profile patch. */
|
|
1661
1738
|
async runFixCommand(arg) {
|
|
1662
1739
|
const name = arg.trim().toLowerCase();
|
|
1663
|
-
const row =
|
|
1740
|
+
const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || (candidate.name ?? '').toLowerCase() === name);
|
|
1664
1741
|
if (row === undefined) {
|
|
1665
1742
|
this.pushRow({
|
|
1666
1743
|
kind: 'error',
|
|
1667
|
-
text: t('doctor.fix.unknownRow', { row: arg.trim(), rows:
|
|
1744
|
+
text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ALL_ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
|
|
1668
1745
|
});
|
|
1669
1746
|
this.markDirty();
|
|
1670
1747
|
return;
|
|
@@ -1683,7 +1760,7 @@ export class SshTui {
|
|
|
1683
1760
|
const duplicates = facts.patch.readable ? planDuplicateRepair(text) : undefined;
|
|
1684
1761
|
if (duplicates !== undefined)
|
|
1685
1762
|
text = duplicates.text;
|
|
1686
|
-
const roster = planRosterRepair(text, rows);
|
|
1763
|
+
const roster = planRosterRepair(text, rows, facts.generation ?? 'legacy');
|
|
1687
1764
|
if (roster !== undefined)
|
|
1688
1765
|
text = roster.text;
|
|
1689
1766
|
const added = roster?.added ?? [];
|
|
@@ -1768,6 +1845,11 @@ export class SshTui {
|
|
|
1768
1845
|
return true;
|
|
1769
1846
|
if (this.compactionRunning())
|
|
1770
1847
|
return true;
|
|
1848
|
+
// A question queued for an answer that has not come. The turn itself has
|
|
1849
|
+
// settled, so nothing else here counts it, and the idle exit would otherwise
|
|
1850
|
+
// kill the Host while it is the only thing still holding that question.
|
|
1851
|
+
if (this.queuedQuestions > 0)
|
|
1852
|
+
return true;
|
|
1771
1853
|
return false;
|
|
1772
1854
|
}
|
|
1773
1855
|
/**
|
|
@@ -1804,6 +1886,13 @@ export class SshTui {
|
|
|
1804
1886
|
this.reattachedDuringHangup = false;
|
|
1805
1887
|
ignoreFurtherHangupSignals();
|
|
1806
1888
|
this.detachDisplay();
|
|
1889
|
+
// Say "no display" now rather than after the cancel/flush below. The lock's
|
|
1890
|
+
// `attached` is what a resume reads to decide that another window is on this
|
|
1891
|
+
// session, and for the whole cancel window it was still set while the window
|
|
1892
|
+
// was already gone — so a reconnect inside that window was refused as
|
|
1893
|
+
// "already attached". A reattach during this hangup patches it back (and the
|
|
1894
|
+
// writes are serialized launcher-side, so the last one wins).
|
|
1895
|
+
await this.onDetach?.();
|
|
1807
1896
|
const busy = this.isBusyForHangupKeepalive();
|
|
1808
1897
|
const pauseTurn = this.disconnectPolicy !== 'continue';
|
|
1809
1898
|
if (pauseTurn && this.agent.status === 'running') {
|
|
@@ -1828,6 +1917,11 @@ export class SshTui {
|
|
|
1828
1917
|
return;
|
|
1829
1918
|
}
|
|
1830
1919
|
const keepHost = this.displayHost !== undefined && busy;
|
|
1920
|
+
if (!keepHost) {
|
|
1921
|
+
// The Host is about to exit, and a marker left behind would keep saying a
|
|
1922
|
+
// question is waiting after the process that held it is gone.
|
|
1923
|
+
void clearWaitingMarker(String(this.agent.id));
|
|
1924
|
+
}
|
|
1831
1925
|
if (keepHost) {
|
|
1832
1926
|
this.hangingUp = false;
|
|
1833
1927
|
// A relay that arrived while this hangup was unwinding set
|
|
@@ -2017,6 +2111,9 @@ export class SshTui {
|
|
|
2017
2111
|
parts.push(t('attach.awayDetached', { count: detached }));
|
|
2018
2112
|
if (waiting > 0)
|
|
2019
2113
|
parts.push(t('attach.awayWaiting', { count: waiting }));
|
|
2114
|
+
if (waiting > 0 && this.questionWaitSince !== undefined) {
|
|
2115
|
+
parts.push(t('attach.questionWaited', { waited: formatShortDuration(Date.now() - this.questionWaitSince) }));
|
|
2116
|
+
}
|
|
2020
2117
|
if (parts.length === 0)
|
|
2021
2118
|
return;
|
|
2022
2119
|
this.pushRow({
|
|
@@ -2066,7 +2163,7 @@ export class SshTui {
|
|
|
2066
2163
|
void this.onReattach?.();
|
|
2067
2164
|
return;
|
|
2068
2165
|
}
|
|
2069
|
-
this.write(
|
|
2166
|
+
this.write(this.enterScreenSequence());
|
|
2070
2167
|
this.forceFullPaint = true;
|
|
2071
2168
|
this.dirty = true;
|
|
2072
2169
|
// The user is back: say so, with how many times this Host has been
|
|
@@ -2080,18 +2177,38 @@ export class SshTui {
|
|
|
2080
2177
|
currentDisconnectPolicy() {
|
|
2081
2178
|
return this.disconnectPolicy;
|
|
2082
2179
|
}
|
|
2180
|
+
/** The paint cadence in force right now: the link tier the footer shows. */
|
|
2181
|
+
currentPaintIntervalMs() {
|
|
2182
|
+
return this.paintIntervalMs;
|
|
2183
|
+
}
|
|
2083
2184
|
applyProbedRtt(rttMs) {
|
|
2084
2185
|
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2085
2186
|
this.paintLink = 'ssh';
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2187
|
+
if (rttMs !== undefined) {
|
|
2188
|
+
// The relay re-measures the link on its own cadence, so this is called
|
|
2189
|
+
// again and again over one session. The reported round-trip is the median
|
|
2190
|
+
// of the last few measurements: a single burst that reads like a
|
|
2191
|
+
// 2-second link must not pin the footer chip — and with it the paint
|
|
2192
|
+
// cadence and the per-frame byte budget — while two measurements that
|
|
2193
|
+
// agree move it at once. That is the fix for a link that was red from the
|
|
2194
|
+
// first second to the last: the first measurement is taken while the
|
|
2195
|
+
// session is still booting, and it used to be the only one ever taken.
|
|
2196
|
+
this.paintRttHistory.push(rttMs);
|
|
2197
|
+
if (this.paintRttHistory.length > RTT_HISTORY)
|
|
2198
|
+
this.paintRttHistory.shift();
|
|
2199
|
+
this.paintProbed = true;
|
|
2200
|
+
this.paintRttMs = medianRtt(this.paintRttHistory);
|
|
2201
|
+
}
|
|
2202
|
+
else if (!this.paintProbed) {
|
|
2203
|
+
// A probe that missed its window reports "unknown". A link that was never
|
|
2204
|
+
// measured (a pipe, a terminal that does not answer DSR) stays unknown and
|
|
2205
|
+
// keeps the unprobed wording; one that has a measurement keeps its number
|
|
2206
|
+
// rather than blanking the chip to four hollow circles after every
|
|
2207
|
+
// auto-reconnect.
|
|
2208
|
+
this.paintRttMs = undefined;
|
|
2209
|
+
}
|
|
2093
2210
|
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
2094
|
-
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
|
|
2211
|
+
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: this.paintRttMs });
|
|
2095
2212
|
if (!this.lineMode)
|
|
2096
2213
|
this.startRenderTimer();
|
|
2097
2214
|
}
|
|
@@ -2168,7 +2285,8 @@ export class SshTui {
|
|
|
2168
2285
|
|| row.kind === 'plan'
|
|
2169
2286
|
|| row.kind === 'question'
|
|
2170
2287
|
|| row.kind === 'goal'
|
|
2171
|
-
|| row.kind === 'compaction'
|
|
2288
|
+
|| row.kind === 'compaction'
|
|
2289
|
+
|| row.kind === 'changes');
|
|
2172
2290
|
for (const burst of compactToolBursts(this.rows)) {
|
|
2173
2291
|
const callAnchor = burst.groups.calls.at(-1);
|
|
2174
2292
|
const editAnchor = burst.groups.edits.at(-1);
|
|
@@ -2186,13 +2304,35 @@ export class SshTui {
|
|
|
2186
2304
|
|| row.kind === 'question'
|
|
2187
2305
|
|| row.kind === 'goal'
|
|
2188
2306
|
|| row.kind === 'compaction'
|
|
2189
|
-
|| row.kind === 'prompt'
|
|
2307
|
+
|| row.kind === 'prompt'
|
|
2308
|
+
|| row.kind === 'changes');
|
|
2190
2309
|
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
2191
2310
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
2192
2311
|
rows.push(this.streamingReasoning);
|
|
2193
2312
|
}
|
|
2194
2313
|
return rows;
|
|
2195
2314
|
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Everything the card cursor walks, in transcript order: the collapsible
|
|
2317
|
+
* cards plus the model replies.
|
|
2318
|
+
*
|
|
2319
|
+
* Built from the two lists rather than from the rows alone, because one of the
|
|
2320
|
+
* collapsible entries is synthetic: the live thinking block exists only while a
|
|
2321
|
+
* turn streams and is never a member of `this.rows`. Filtering the rows (the
|
|
2322
|
+
* first version of this) silently dropped it out of the cursor walk — ↑ on a
|
|
2323
|
+
* turn that had only streamed thinking did nothing, and Enter could no longer
|
|
2324
|
+
* expand the card being written. It belongs at the end, which is where it is
|
|
2325
|
+
* painted, so anything not in the rows is appended.
|
|
2326
|
+
*/
|
|
2327
|
+
focusRing() {
|
|
2328
|
+
const collapsible = this.collapsibleRows();
|
|
2329
|
+
const ring = this.rows.filter(row => row.kind === 'assistant' || collapsible.includes(row));
|
|
2330
|
+
const known = new Set(this.rows);
|
|
2331
|
+
for (const row of collapsible)
|
|
2332
|
+
if (!known.has(row))
|
|
2333
|
+
ring.push(row);
|
|
2334
|
+
return ring;
|
|
2335
|
+
}
|
|
2196
2336
|
spinnerFrame(periodMs = 120) {
|
|
2197
2337
|
return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
|
|
2198
2338
|
}
|
|
@@ -2330,7 +2470,7 @@ export class SshTui {
|
|
|
2330
2470
|
// instruction that used to paint as `❯ …`.
|
|
2331
2471
|
const message = createUserMessage({
|
|
2332
2472
|
content: [{ type: 'text', text }],
|
|
2333
|
-
source: { kind:
|
|
2473
|
+
source: { kind: TUI_SOURCE_KIND, form: 'notice', summary: queued },
|
|
2334
2474
|
});
|
|
2335
2475
|
try {
|
|
2336
2476
|
this.agent.followup(message);
|
|
@@ -2375,7 +2515,7 @@ export class SshTui {
|
|
|
2375
2515
|
const note = planDockNote(plan);
|
|
2376
2516
|
lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
|
|
2377
2517
|
if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
|
|
2378
|
-
const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
|
|
2518
|
+
const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color, this.terminalCaps.osc8);
|
|
2379
2519
|
const budget = Math.max(4, Math.min(12, markdown.length));
|
|
2380
2520
|
for (const line of markdown.slice(0, budget)) {
|
|
2381
2521
|
lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
|
|
@@ -2541,7 +2681,10 @@ export class SshTui {
|
|
|
2541
2681
|
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
2542
2682
|
return;
|
|
2543
2683
|
const header = this.styleLine('system', truncateToWidth(dialog.title, width));
|
|
2544
|
-
|
|
2684
|
+
// A copy from inside the overlay replaces the key legend for the rest of the
|
|
2685
|
+
// visit: the notice row it also pushes is behind this screen, and a copy the
|
|
2686
|
+
// reader cannot confirm is the one thing worse than no copy at all.
|
|
2687
|
+
const hint = this.styleLine('system', truncateToWidth(dialog.notice ?? t('tool.inspectHint'), width));
|
|
2545
2688
|
const divider = this.styleLine('system', repeatToWidth('─', width));
|
|
2546
2689
|
const bodyBudget = Math.max(1, height - 4);
|
|
2547
2690
|
const rendered = [];
|
|
@@ -2618,6 +2761,7 @@ export class SshTui {
|
|
|
2618
2761
|
title,
|
|
2619
2762
|
lines,
|
|
2620
2763
|
offset: 0,
|
|
2764
|
+
copyText: inspectCopyText(lines),
|
|
2621
2765
|
});
|
|
2622
2766
|
}
|
|
2623
2767
|
openSubagentInspect(row) {
|
|
@@ -2625,6 +2769,8 @@ export class SshTui {
|
|
|
2625
2769
|
if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
|
|
2626
2770
|
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
2627
2771
|
dialog.lines = subagentInspectLines(row);
|
|
2772
|
+
dialog.copyText = inspectCopyText(dialog.lines);
|
|
2773
|
+
dialog.notice = undefined;
|
|
2628
2774
|
this.markDirty();
|
|
2629
2775
|
return;
|
|
2630
2776
|
}
|
|
@@ -2638,8 +2784,36 @@ export class SshTui {
|
|
|
2638
2784
|
lines,
|
|
2639
2785
|
offset: 0,
|
|
2640
2786
|
subagentSessionId: this.subagentInspectId(row),
|
|
2787
|
+
copyText: inspectCopyText(lines),
|
|
2641
2788
|
});
|
|
2642
2789
|
}
|
|
2790
|
+
/**
|
|
2791
|
+
* Read one reply full-screen.
|
|
2792
|
+
*
|
|
2793
|
+
* What Enter does on a selected reply: a reply has no body to fold away, and
|
|
2794
|
+
* the transcript wraps it to the window — which for a long answer with tables
|
|
2795
|
+
* or code blocks is not how it reads. The overlay is the same surface the tool
|
|
2796
|
+
* cards use, and the copy key works inside it, so "read it, then copy it"
|
|
2797
|
+
* needs no trip back through the timeline.
|
|
2798
|
+
*/
|
|
2799
|
+
openReplyInspect(row) {
|
|
2800
|
+
// Line mode has no scroller, and `echoInspectToLog` would append the reply
|
|
2801
|
+
// to the log a second time — it is already there, in full. Nothing to open.
|
|
2802
|
+
if (this.lineMode)
|
|
2803
|
+
return;
|
|
2804
|
+
// `- 2`, matching `paintInspectOverlay`, which re-wraps every body line at
|
|
2805
|
+
// `width - 2` inside a two-space indent: rendered wider, long table and code
|
|
2806
|
+
// rows were re-wrapped mid-cell at narrow widths.
|
|
2807
|
+
const width = Math.max(20, this.screenColumns() - 2);
|
|
2808
|
+
// Plain: `styleLine` strips escapes, so a coloured render would lose its
|
|
2809
|
+
// styling anyway — and the overlay's own frame supplies the base style.
|
|
2810
|
+
const lines = renderMarkdownLines(row.text, width, false, false)
|
|
2811
|
+
.map(line => ({ kind: 'assistant', text: line }));
|
|
2812
|
+
const title = t('reply.inspectTitle', { lines: lines.length });
|
|
2813
|
+
if (this.echoInspectToLog(title, lines))
|
|
2814
|
+
return;
|
|
2815
|
+
this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: row.text });
|
|
2816
|
+
}
|
|
2643
2817
|
/**
|
|
2644
2818
|
* Line mode has no framed scroller, so an inspect body goes into the log.
|
|
2645
2819
|
*
|
|
@@ -2684,9 +2858,27 @@ export class SshTui {
|
|
|
2684
2858
|
addDisplay(this.selectLine(styled, focused), row);
|
|
2685
2859
|
}
|
|
2686
2860
|
}
|
|
2687
|
-
/**
|
|
2688
|
-
|
|
2689
|
-
|
|
2861
|
+
/**
|
|
2862
|
+
* Which file line of an expanded changes card the screen cursor is on.
|
|
2863
|
+
*
|
|
2864
|
+
* The card paints its header, then one row per file, and `selectableLines`
|
|
2865
|
+
* holds exactly the viewport. The cursor sits on the input row unless the
|
|
2866
|
+
* reader scrolled, so the file is how many painted rows above the cursor the
|
|
2867
|
+
* card's header is. A cursor on the header, the remainder line, or the prompt
|
|
2868
|
+
* falls back to the first file, which is the only defensible guess.
|
|
2869
|
+
*/
|
|
2870
|
+
changesFileIndex(row) {
|
|
2871
|
+
const painted = this.selectableLines.findIndex(line => line.ref === row);
|
|
2872
|
+
if (painted < 0)
|
|
2873
|
+
return 0;
|
|
2874
|
+
const index = this.lastPaintCursorRow - 1 - (this.lastTranscriptStart + painted);
|
|
2875
|
+
if (index < 1 || index > row.files.length)
|
|
2876
|
+
return 0;
|
|
2877
|
+
return index - 1;
|
|
2878
|
+
}
|
|
2879
|
+
/** Move the card cursor among the cards and the replies. */
|
|
2880
|
+
moveFocus(delta) {
|
|
2881
|
+
const rows = this.focusRing();
|
|
2690
2882
|
if (rows.length === 0)
|
|
2691
2883
|
return;
|
|
2692
2884
|
if (this.focusedRow === null) {
|
|
@@ -2704,13 +2896,23 @@ export class SshTui {
|
|
|
2704
2896
|
}
|
|
2705
2897
|
/** Toggle the focused block; without focus, toggle the most recent one. */
|
|
2706
2898
|
toggleCollapsible() {
|
|
2899
|
+
const focused = this.focusedRow;
|
|
2900
|
+
// A reply has no body to fold away, so Enter reads it full-screen instead.
|
|
2901
|
+
// Falling through to the lookup below would be worse than doing nothing:
|
|
2902
|
+
// a reply is not in `collapsibleRows()`, and the fallback is "the newest
|
|
2903
|
+
// card" — Enter on a selected reply would expand a card the reader never
|
|
2904
|
+
// picked.
|
|
2905
|
+
if (focused !== null && focused.kind === 'assistant') {
|
|
2906
|
+
this.openReplyInspect(focused);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2707
2909
|
const rows = this.collapsibleRows();
|
|
2708
2910
|
if (rows.length === 0)
|
|
2709
2911
|
return;
|
|
2710
|
-
const
|
|
2711
|
-
?
|
|
2912
|
+
const inRing = focused !== null && rows.includes(focused)
|
|
2913
|
+
? focused
|
|
2712
2914
|
: undefined;
|
|
2713
|
-
const target =
|
|
2915
|
+
const target = inRing ?? rows[rows.length - 1];
|
|
2714
2916
|
if (target === undefined)
|
|
2715
2917
|
return;
|
|
2716
2918
|
this.toggleCard(target);
|
|
@@ -2731,6 +2933,14 @@ export class SshTui {
|
|
|
2731
2933
|
return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
|
|
2732
2934
|
}
|
|
2733
2935
|
toggleCard(target) {
|
|
2936
|
+
// A changes card opens its file list first; the next Enter reads the file
|
|
2937
|
+
// the cursor is on. With one file there is nothing to choose, so that Enter
|
|
2938
|
+
// opens it directly.
|
|
2939
|
+
if (target.kind === 'changes' && target.expanded && target.files.length > 0) {
|
|
2940
|
+
this.focusedRow = target;
|
|
2941
|
+
this.openChangesInspect(target, target.files.length === 1 ? 0 : this.changesFileIndex(target));
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2734
2944
|
if (target.kind === 'subagent') {
|
|
2735
2945
|
this.focusedRow = target;
|
|
2736
2946
|
this.openSubagentInspect(target);
|
|
@@ -2755,11 +2965,14 @@ export class SshTui {
|
|
|
2755
2965
|
const rows = this.collapsibleRows();
|
|
2756
2966
|
if (rows.length === 0)
|
|
2757
2967
|
return;
|
|
2968
|
+
// Ctrl+R acts on the cards; a selected reply is not one, and it must survive
|
|
2969
|
+
// the sweep either way (it was not collapsed, so it must not be deselected).
|
|
2970
|
+
const selected = this.focusedRow?.kind === 'assistant' ? this.focusedRow : undefined;
|
|
2758
2971
|
const allExpanded = rows.every(row => row.expanded);
|
|
2759
2972
|
if (allExpanded) {
|
|
2760
2973
|
for (const row of rows)
|
|
2761
2974
|
row.expanded = false;
|
|
2762
|
-
this.focusedRow = null;
|
|
2975
|
+
this.focusedRow = selected ?? null;
|
|
2763
2976
|
}
|
|
2764
2977
|
else {
|
|
2765
2978
|
const width = Math.max(10, this.screenColumns());
|
|
@@ -2775,7 +2988,7 @@ export class SshTui {
|
|
|
2775
2988
|
}
|
|
2776
2989
|
row.expanded = true;
|
|
2777
2990
|
}
|
|
2778
|
-
this.focusedRow = rows[rows.length - 1] ?? null;
|
|
2991
|
+
this.focusedRow = selected ?? rows[rows.length - 1] ?? null;
|
|
2779
2992
|
}
|
|
2780
2993
|
this.forceFullPaint = true;
|
|
2781
2994
|
this.markDirty();
|
|
@@ -2839,6 +3052,14 @@ export class SshTui {
|
|
|
2839
3052
|
this.focusedRow = row;
|
|
2840
3053
|
this.forceFullPaint = true;
|
|
2841
3054
|
}
|
|
3055
|
+
else if (row.kind === 'assistant') {
|
|
3056
|
+
// A reply has nothing to expand, but revealing it must leave it selected:
|
|
3057
|
+
// Alt+4 and `/find 回复` land here, and clearing the focus (which is what
|
|
3058
|
+
// this branch used to do) meant `/copy` afterwards only worked by falling
|
|
3059
|
+
// back to "the latest reply" — silently, and only when it happened to be
|
|
3060
|
+
// the same row.
|
|
3061
|
+
this.focusedRow = row;
|
|
3062
|
+
}
|
|
2842
3063
|
else {
|
|
2843
3064
|
this.focusedRow = null;
|
|
2844
3065
|
}
|
|
@@ -2937,14 +3158,39 @@ export class SshTui {
|
|
|
2937
3158
|
}
|
|
2938
3159
|
const display = [];
|
|
2939
3160
|
const displayRefs = [];
|
|
3161
|
+
// Cells at the start of each painted line that are chrome rather than text.
|
|
3162
|
+
// Only the focus marker has any, and the drag-selection needs to know how
|
|
3163
|
+
// many so a copy off the selected row does not paste the marker.
|
|
3164
|
+
const displayGutters = [];
|
|
2940
3165
|
const searchHit = this.searchHits[this.searchIndex];
|
|
2941
|
-
const addDisplay = (line, ref) => {
|
|
3166
|
+
const addDisplay = (line, ref, gutter = 0) => {
|
|
2942
3167
|
display.push(clipAnsiToWidth(line, width));
|
|
2943
3168
|
displayRefs.push(ref);
|
|
3169
|
+
displayGutters.push(gutter);
|
|
2944
3170
|
};
|
|
2945
3171
|
const pushRow = (kind, text, ref) => {
|
|
2946
3172
|
if (kind === 'assistant') {
|
|
2947
|
-
|
|
3173
|
+
const focused = ref !== undefined && this.focusedRow === ref;
|
|
3174
|
+
// The same string the card headers use, so the ASCII fallback rewrites
|
|
3175
|
+
// both alike, and the width comes from it rather than from a constant
|
|
3176
|
+
// (in ASCII `▶` becomes `> `, and the marker is still three cells).
|
|
3177
|
+
const marker = sanitizeTerminalText(REPLY_FOCUS_MARKER);
|
|
3178
|
+
const gutter = displayWidth(marker);
|
|
3179
|
+
// A focused reply is rendered three cells narrower so the marker cannot
|
|
3180
|
+
// eat its last characters: painted at full width and then prefixed, the
|
|
3181
|
+
// tail of every one of its lines was clipped away silently (the card
|
|
3182
|
+
// headers avoid this by truncating to the same kind of margin).
|
|
3183
|
+
const inner = focused ? Math.max(1, width - gutter) : width;
|
|
3184
|
+
let placed = false;
|
|
3185
|
+
for (const line of renderMarkdownLines(text, inner, this.color, this.terminalCaps.osc8)) {
|
|
3186
|
+
// The marker goes on the first line that has something on it: a reply
|
|
3187
|
+
// starting with a blank line would otherwise carry a marker all by
|
|
3188
|
+
// itself, with the reader's selection nowhere near its text.
|
|
3189
|
+
if (focused && !placed && stripAnsi(line).trim() !== '') {
|
|
3190
|
+
addDisplay(`${marker}${line}`, ref, gutter);
|
|
3191
|
+
placed = true;
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
2948
3194
|
addDisplay(line, ref);
|
|
2949
3195
|
}
|
|
2950
3196
|
return;
|
|
@@ -3087,7 +3333,7 @@ export class SshTui {
|
|
|
3087
3333
|
if (row.expanded) {
|
|
3088
3334
|
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
|
|
3089
3335
|
if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
|
|
3090
|
-
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
|
|
3336
|
+
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 8)) {
|
|
3091
3337
|
addDisplay(` ${line}`, row);
|
|
3092
3338
|
}
|
|
3093
3339
|
}
|
|
@@ -3125,7 +3371,7 @@ export class SshTui {
|
|
|
3125
3371
|
}
|
|
3126
3372
|
if (row.detail !== undefined && row.detail !== '') {
|
|
3127
3373
|
if (row.intent === 'plan-review') {
|
|
3128
|
-
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
|
|
3374
|
+
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8)) {
|
|
3129
3375
|
addDisplay(` ${line}`, row);
|
|
3130
3376
|
}
|
|
3131
3377
|
}
|
|
@@ -3161,6 +3407,18 @@ export class SshTui {
|
|
|
3161
3407
|
}
|
|
3162
3408
|
continue;
|
|
3163
3409
|
}
|
|
3410
|
+
if (row.kind === 'changes') {
|
|
3411
|
+
const header = `${row.header}${row.expanded ? '' : t('card.expand')}`;
|
|
3412
|
+
this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width);
|
|
3413
|
+
if (row.expanded) {
|
|
3414
|
+
for (const file of row.files) {
|
|
3415
|
+
addDisplay(this.styleLine('tool-result', ` ${file}`), row);
|
|
3416
|
+
}
|
|
3417
|
+
if (row.more !== undefined)
|
|
3418
|
+
addDisplay(this.styleLine('system', ` ${row.more}`), row);
|
|
3419
|
+
}
|
|
3420
|
+
continue;
|
|
3421
|
+
}
|
|
3164
3422
|
if (row.kind === 'compaction') {
|
|
3165
3423
|
const running = row.status === 'running';
|
|
3166
3424
|
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
@@ -3408,7 +3666,7 @@ export class SshTui {
|
|
|
3408
3666
|
addDialog(t('dialog.planReview', { index: d.index + 1, total: d.total }) + (d.question.header === undefined ? '' : ` · ${d.question.header}`));
|
|
3409
3667
|
addDialog(d.question.question);
|
|
3410
3668
|
if (d.question.detail !== undefined && d.question.detail !== '') {
|
|
3411
|
-
for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
|
|
3669
|
+
for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 16)) {
|
|
3412
3670
|
dialogLines.push(this.styleLine('assistant', line));
|
|
3413
3671
|
}
|
|
3414
3672
|
}
|
|
@@ -3548,6 +3806,10 @@ export class SshTui {
|
|
|
3548
3806
|
const start = window.start;
|
|
3549
3807
|
const visible = window.visibleLines;
|
|
3550
3808
|
const visibleRefs = window.visibleRefs;
|
|
3809
|
+
// `padding` blank rows are unshifted when the transcript is shorter than the
|
|
3810
|
+
// window, so the gutter of a visible line is read off the same offset the
|
|
3811
|
+
// window itself used (negative before the transcript starts → 0).
|
|
3812
|
+
const visibleGutters = visible.map((_, index) => displayGutters[start + index - window.padding] ?? 0);
|
|
3551
3813
|
this.clickableRows.clear();
|
|
3552
3814
|
this.paintedLinkHitsByRow.clear();
|
|
3553
3815
|
this.transcriptTopScreenY = headerLines.length + 1;
|
|
@@ -3559,6 +3821,7 @@ export class SshTui {
|
|
|
3559
3821
|
// Only a model reply is freely copyable; a tool card, a notice or the
|
|
3560
3822
|
// chrome keeps its click behavior instead.
|
|
3561
3823
|
copyable: visibleRefs[index]?.kind === 'assistant',
|
|
3824
|
+
gutter: visibleGutters[index] ?? 0,
|
|
3562
3825
|
}));
|
|
3563
3826
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
3564
3827
|
const ref = visibleRefs[index];
|
|
@@ -3670,7 +3933,7 @@ export class SshTui {
|
|
|
3670
3933
|
// the session counters go before the operational signals. Quota and context
|
|
3671
3934
|
// pressure are deliberately not here — they are on the identity line.
|
|
3672
3935
|
const strip = [];
|
|
3673
|
-
const health = footerHealthChip(this.rosterMissing, this.color);
|
|
3936
|
+
const health = footerHealthChip(this.rosterMissing, this.color, this.settingsGeneration === 'forms' ? 'agent-plane' : 'roster');
|
|
3674
3937
|
// Muted like the identity line, accent excepted: the pre-strip footer
|
|
3675
3938
|
// painted the counters dim and only the ⚠'s own glyph yellow.
|
|
3676
3939
|
if (health !== undefined)
|
|
@@ -3828,6 +4091,10 @@ export class SshTui {
|
|
|
3828
4091
|
updateTerminalTitle() {
|
|
3829
4092
|
if (this.exiting || this.lineMode)
|
|
3830
4093
|
return;
|
|
4094
|
+
// A console that has no title to set (the Linux virtual console) would take
|
|
4095
|
+
// the escape as noise; the matrix says so, so honour it.
|
|
4096
|
+
if (!this.terminalCaps.title)
|
|
4097
|
+
return;
|
|
3831
4098
|
const now = Date.now();
|
|
3832
4099
|
// Completion wins over a still-running agent status: the turn/end event
|
|
3833
4100
|
// lands before agent/status flips to idle, and the title must not stay
|
|
@@ -3936,13 +4203,7 @@ export class SshTui {
|
|
|
3936
4203
|
if (service === undefined) {
|
|
3937
4204
|
this.pushRow({
|
|
3938
4205
|
kind: 'error',
|
|
3939
|
-
text:
|
|
3940
|
-
t('preset.missingService'),
|
|
3941
|
-
t('mode.missingServiceHint', {
|
|
3942
|
-
profile: profileFromArgv(),
|
|
3943
|
-
patch: rosterPatchPath(resolveDshHome(), profileFromArgv()),
|
|
3944
|
-
}),
|
|
3945
|
-
].join('\n'),
|
|
4206
|
+
text: this.missingPresetService(t('preset.missingService')),
|
|
3946
4207
|
});
|
|
3947
4208
|
this.markDirty();
|
|
3948
4209
|
return;
|
|
@@ -4122,9 +4383,11 @@ export class SshTui {
|
|
|
4122
4383
|
}
|
|
4123
4384
|
const lines = [t('preset.showTitle', { id: preset.id, name: preset.name ?? preset.id })];
|
|
4124
4385
|
lines.push(t('preset.showMeta', {
|
|
4125
|
-
trust: preset.trust === 'user'
|
|
4386
|
+
trust: preset.trust === 'user'
|
|
4387
|
+
? t('preset.trustUser')
|
|
4388
|
+
: preset.trust === undefined ? t('preset.trustManaged') : t('preset.trustSystem'),
|
|
4126
4389
|
order: preset.order === undefined ? '—' : String(preset.order),
|
|
4127
|
-
directory: presetDirectory(preset),
|
|
4390
|
+
directory: presetDirectory(preset) ?? t('preset.directoryManaged'),
|
|
4128
4391
|
}));
|
|
4129
4392
|
if (preset.description !== undefined)
|
|
4130
4393
|
lines.push(t('preset.showDescription', { description: preset.description }));
|
|
@@ -4189,7 +4452,7 @@ export class SshTui {
|
|
|
4189
4452
|
from: plan.from,
|
|
4190
4453
|
id: plan.id,
|
|
4191
4454
|
name: plan.name === undefined ? '' : ` · ${plan.name}`,
|
|
4192
|
-
directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]),
|
|
4455
|
+
directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]) ?? '',
|
|
4193
4456
|
});
|
|
4194
4457
|
});
|
|
4195
4458
|
}
|
|
@@ -4212,7 +4475,8 @@ export class SshTui {
|
|
|
4212
4475
|
}
|
|
4213
4476
|
// Merge with what the preset publishes now, so editing one field never
|
|
4214
4477
|
// drops the other and `order` survives.
|
|
4215
|
-
const
|
|
4478
|
+
const directory = presetDirectory(preset);
|
|
4479
|
+
const current = directory === undefined ? {} : await readPresetMetadata(directory);
|
|
4216
4480
|
const plan = planMetadata({ preset, current, patch });
|
|
4217
4481
|
if (isRefusal(plan)) {
|
|
4218
4482
|
this.pushPresetRefusal(plan);
|
|
@@ -4255,7 +4519,7 @@ export class SshTui {
|
|
|
4255
4519
|
return;
|
|
4256
4520
|
}
|
|
4257
4521
|
const answer = await new Promise(resolve => {
|
|
4258
|
-
this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) }), resolve);
|
|
4522
|
+
this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) ?? t('preset.directoryManaged') }), resolve);
|
|
4259
4523
|
});
|
|
4260
4524
|
if (answer !== 'y') {
|
|
4261
4525
|
this.pushRow({ kind: 'system', text: t('preset.deleteCancelled') });
|
|
@@ -4384,11 +4648,11 @@ export class SshTui {
|
|
|
4384
4648
|
return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
|
|
4385
4649
|
}
|
|
4386
4650
|
/**
|
|
4387
|
-
* Fold one live
|
|
4388
|
-
*
|
|
4389
|
-
*
|
|
4651
|
+
* Fold one live stream chunk into the in-progress assistant row. Hosts from
|
|
4652
|
+
* 0.1.5 on emit the chunk on `agent/assistant-stream` and never write it to
|
|
4653
|
+
* the log.
|
|
4390
4654
|
*/
|
|
4391
|
-
applyStreamChunk(streamed
|
|
4655
|
+
applyStreamChunk(streamed) {
|
|
4392
4656
|
const { chunk } = streamed;
|
|
4393
4657
|
if (isTokenDeltaChunk(chunk)) {
|
|
4394
4658
|
this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
|
|
@@ -4396,8 +4660,6 @@ export class SshTui {
|
|
|
4396
4660
|
if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
|
|
4397
4661
|
this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
4398
4662
|
}
|
|
4399
|
-
if (statsOnly)
|
|
4400
|
-
return;
|
|
4401
4663
|
if (chunk.type === 'text-delta') {
|
|
4402
4664
|
this.streaming ??= { text: '', reasoning: '' };
|
|
4403
4665
|
this.streaming.text += chunk.text ?? '';
|
|
@@ -4414,9 +4676,9 @@ export class SshTui {
|
|
|
4414
4676
|
}
|
|
4415
4677
|
}
|
|
4416
4678
|
/**
|
|
4417
|
-
* 0.1.5 live tokens arrive as process-local
|
|
4418
|
-
* frames (start / chunk / end). Chunk frames carry
|
|
4419
|
-
* `StreamChunk` the
|
|
4679
|
+
* 0.1.5 and 0.1.7 live tokens arrive as process-local
|
|
4680
|
+
* `agent/assistant-stream` frames (start / chunk / end). Chunk frames carry
|
|
4681
|
+
* the same `StreamChunk` the settled `assistant/message` embeds in `stream`.
|
|
4420
4682
|
*/
|
|
4421
4683
|
handleAssistantStream = (...args) => {
|
|
4422
4684
|
const payload = args[0];
|
|
@@ -4505,21 +4767,13 @@ export class SshTui {
|
|
|
4505
4767
|
return;
|
|
4506
4768
|
}
|
|
4507
4769
|
this.lastActivity = Date.now();
|
|
4508
|
-
if (this.replaying && isAssistantStreamEvent(event)) {
|
|
4509
|
-
// Replay skips the in-progress row, but durable chunks still carry the
|
|
4510
|
-
// timing the footer needs: a resumed session must keep TTFT and tok/s.
|
|
4511
|
-
const streamed = streamChunkOf(event);
|
|
4512
|
-
if (streamed !== undefined)
|
|
4513
|
-
this.applyStreamChunk(streamed, true);
|
|
4514
|
-
return;
|
|
4515
|
-
}
|
|
4516
4770
|
if (!this.replaying)
|
|
4517
4771
|
this.refreshContextPressure();
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4772
|
+
// Before the switch, and by string: `workspace/changes` is not a key of the
|
|
4773
|
+
// 0.1.5 `SessionEventMap`, so a `case` for it fails strict compilation on
|
|
4774
|
+
// that tree. A Host without the service records nothing and this returns.
|
|
4775
|
+
if (String(event.type) === 'workspace/changes') {
|
|
4776
|
+
this.noteWorkspaceChanges(session.id, event.seq);
|
|
4523
4777
|
return;
|
|
4524
4778
|
}
|
|
4525
4779
|
switch (event.type) {
|
|
@@ -4536,7 +4790,7 @@ export class SshTui {
|
|
|
4536
4790
|
if (!this.replaying)
|
|
4537
4791
|
this.beginWait();
|
|
4538
4792
|
}
|
|
4539
|
-
else if (
|
|
4793
|
+
else if (source.form === 'notice') {
|
|
4540
4794
|
const summary = source.summary?.trim() ?? '';
|
|
4541
4795
|
// Body stays off the workspace (the model still received it).
|
|
4542
4796
|
const last = this.rows.at(-1);
|
|
@@ -4553,12 +4807,17 @@ export class SshTui {
|
|
|
4553
4807
|
if (!this.replaying)
|
|
4554
4808
|
this.beginWait();
|
|
4555
4809
|
}
|
|
4810
|
+
else if (source.form === 'snapshot') {
|
|
4811
|
+
// `form` is the ContextFormed discriminator every producer shares,
|
|
4812
|
+
// while `kind` is now per-producer (0.1.7 has no catch-all), so the
|
|
4813
|
+
// two content branches above key on the form: keying them on the
|
|
4814
|
+
// released `plugin` kind dropped a `time-context` snapshot — and
|
|
4815
|
+
// this plugin's own notices — into the raw-context row below.
|
|
4816
|
+
this.pushRow({ kind: 'system', text: text });
|
|
4817
|
+
}
|
|
4556
4818
|
else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
|
|
4557
4819
|
this.pushPromptInjection(text, source.plugin);
|
|
4558
4820
|
}
|
|
4559
|
-
else if (sourceKind === 'plugin' && source.form === 'snapshot') {
|
|
4560
|
-
this.pushRow({ kind: 'system', text: text });
|
|
4561
|
-
}
|
|
4562
4821
|
else {
|
|
4563
4822
|
this.pushRow({ kind: 'system', text: t('prompt.contextPrefix', { text }) });
|
|
4564
4823
|
}
|
|
@@ -4579,8 +4838,7 @@ export class SshTui {
|
|
|
4579
4838
|
.join('');
|
|
4580
4839
|
// The settlement's own packed stream is authoritative: a retried step
|
|
4581
4840
|
// keeps the failed attempt's first token in the live latch, and spanning
|
|
4582
|
-
// both attempts reported a rate ~20x off.
|
|
4583
|
-
// relies on the replayed `assistant/chunk` events (or the live latch).
|
|
4841
|
+
// both attempts reported a rate ~20x off.
|
|
4584
4842
|
this.statsTracker.settleMessage({
|
|
4585
4843
|
turn: event.data.turn,
|
|
4586
4844
|
step: event.data.step,
|
|
@@ -4600,7 +4858,10 @@ export class SshTui {
|
|
|
4600
4858
|
if (reasoning !== '') {
|
|
4601
4859
|
this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
|
|
4602
4860
|
}
|
|
4861
|
+
if (reasoning !== '')
|
|
4862
|
+
this.turnSawReasoning = true;
|
|
4603
4863
|
if (text !== '') {
|
|
4864
|
+
this.turnSawOutput = true;
|
|
4604
4865
|
this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
|
|
4605
4866
|
}
|
|
4606
4867
|
else if (interrupted && reasoning === '') {
|
|
@@ -4610,6 +4871,7 @@ export class SshTui {
|
|
|
4610
4871
|
break;
|
|
4611
4872
|
}
|
|
4612
4873
|
case 'tool/call': {
|
|
4874
|
+
this.turnSawOutput = true;
|
|
4613
4875
|
this.openToolCalls.set(String(event.data.callId), event.data.name);
|
|
4614
4876
|
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
4615
4877
|
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
@@ -4694,7 +4956,7 @@ export class SshTui {
|
|
|
4694
4956
|
this.markDirty();
|
|
4695
4957
|
break;
|
|
4696
4958
|
}
|
|
4697
|
-
const failed = event.data.error !== undefined || event.data.message
|
|
4959
|
+
const failed = event.data.error !== undefined || toolResultFailed(event.data.message);
|
|
4698
4960
|
spawnCard.status = failed ? 'error' : 'ok';
|
|
4699
4961
|
spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
|
|
4700
4962
|
if (output !== '') {
|
|
@@ -4725,7 +4987,7 @@ export class SshTui {
|
|
|
4725
4987
|
row.output = output;
|
|
4726
4988
|
}
|
|
4727
4989
|
const failed = event.data.error !== undefined
|
|
4728
|
-
|| event.data.message
|
|
4990
|
+
|| toolResultFailed(event.data.message)
|
|
4729
4991
|
|| (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
|
|
4730
4992
|
row.status = failed ? 'error' : 'ok';
|
|
4731
4993
|
if (READ_TOOL_NAMES.has(row.name)) {
|
|
@@ -4793,6 +5055,8 @@ export class SshTui {
|
|
|
4793
5055
|
break;
|
|
4794
5056
|
case 'turn/start':
|
|
4795
5057
|
this.stalledWarningShown = false;
|
|
5058
|
+
this.turnSawOutput = false;
|
|
5059
|
+
this.turnSawReasoning = false;
|
|
4796
5060
|
this.llmRetry = undefined;
|
|
4797
5061
|
this.status = `turn ${event.data.turn} running`;
|
|
4798
5062
|
// A turn is about to spend on this route: make sure the session's record
|
|
@@ -4831,6 +5095,17 @@ export class SshTui {
|
|
|
4831
5095
|
if (reason.kind === 'error') {
|
|
4832
5096
|
this.pushRow({ kind: 'error', text: t('turn.failed', { turn: event.data.turn, error: reason.error.message }) });
|
|
4833
5097
|
}
|
|
5098
|
+
if (reason.kind === 'completed' && !this.replaying && !this.turnSawOutput) {
|
|
5099
|
+
// The provider ended the turn without an answer. Some gateways map a
|
|
5100
|
+
// Gemini/Claude reply that only contains a thought part onto
|
|
5101
|
+
// `reasoning_content` and then finish with stop and no content, so the
|
|
5102
|
+
// turn looks completed while nothing was said and the user is left to
|
|
5103
|
+
// guess that another Enter is what continues it. Say so instead.
|
|
5104
|
+
this.pushRow({
|
|
5105
|
+
kind: 'system',
|
|
5106
|
+
text: t(this.turnSawReasoning ? 'turn.emptyThinkingOnly' : 'turn.emptyReply'),
|
|
5107
|
+
});
|
|
5108
|
+
}
|
|
4834
5109
|
const livePlan = this.findLivePlanRow();
|
|
4835
5110
|
if (livePlan !== undefined && reason.kind === 'completed') {
|
|
4836
5111
|
applyTurnEndToPlan(livePlan);
|
|
@@ -4916,6 +5191,100 @@ export class SshTui {
|
|
|
4916
5191
|
this.status = 'disposed';
|
|
4917
5192
|
this.markDirty();
|
|
4918
5193
|
};
|
|
5194
|
+
/**
|
|
5195
|
+
* One `workspace/changes` event: the files a turn changed.
|
|
5196
|
+
*
|
|
5197
|
+
* The event carries only the turn number; the summary stays on the Host and
|
|
5198
|
+
* is served by `workspaceChanges` for this event's own sequence, and only
|
|
5199
|
+
* while the Session lives. A missing service (every 0.1.5 Host) or a summary
|
|
5200
|
+
* that can no longer be opened (a log replayed after a restart) shows
|
|
5201
|
+
* nothing — upstream's rule, so a card that cannot be read is never drawn.
|
|
5202
|
+
* A later event for the same turn replaces the earlier summary in place.
|
|
5203
|
+
*/
|
|
5204
|
+
noteWorkspaceChanges(sessionId, seq) {
|
|
5205
|
+
if (typeof seq !== 'number')
|
|
5206
|
+
return;
|
|
5207
|
+
const service = workspaceChangesOf(this.ctx);
|
|
5208
|
+
if (service === undefined)
|
|
5209
|
+
return;
|
|
5210
|
+
const summary = service.summary(String(sessionId), seq);
|
|
5211
|
+
if (summary === undefined || !changesSummaryVisible(summary))
|
|
5212
|
+
return;
|
|
5213
|
+
const existing = this.rows.findLast((row) => row.kind === 'changes' && row.turn === summary.turn && row.sessionId === String(sessionId));
|
|
5214
|
+
if (existing !== undefined) {
|
|
5215
|
+
this.fillChangesCard(existing, summary, seq);
|
|
5216
|
+
}
|
|
5217
|
+
else {
|
|
5218
|
+
const row = {
|
|
5219
|
+
kind: 'changes',
|
|
5220
|
+
turn: summary.turn,
|
|
5221
|
+
seq,
|
|
5222
|
+
sessionId: String(sessionId),
|
|
5223
|
+
header: '',
|
|
5224
|
+
files: [],
|
|
5225
|
+
expanded: false,
|
|
5226
|
+
};
|
|
5227
|
+
this.fillChangesCard(row, summary, seq);
|
|
5228
|
+
this.pushRow(row);
|
|
5229
|
+
}
|
|
5230
|
+
this.markDirty();
|
|
5231
|
+
}
|
|
5232
|
+
/** Copy one summary onto its card. Shared by the first event and its replacements. */
|
|
5233
|
+
fillChangesCard(row, summary, seq) {
|
|
5234
|
+
row.seq = seq;
|
|
5235
|
+
row.header = changesHeader(summary);
|
|
5236
|
+
row.files = summary.files.map(changesFileLine);
|
|
5237
|
+
const more = changesRemainderLine(summary);
|
|
5238
|
+
if (more === undefined)
|
|
5239
|
+
delete row.more;
|
|
5240
|
+
else
|
|
5241
|
+
row.more = more;
|
|
5242
|
+
}
|
|
5243
|
+
/**
|
|
5244
|
+
* Full view of one file on a changes card: its comparison, as the service
|
|
5245
|
+
* computed it. Binary and oversized files have no lines, so the overlay
|
|
5246
|
+
* carries the one-line explanation instead of an empty body.
|
|
5247
|
+
*/
|
|
5248
|
+
openChangesInspect(row, index) {
|
|
5249
|
+
const service = workspaceChangesOf(this.ctx);
|
|
5250
|
+
const label = row.files[index] ?? row.header;
|
|
5251
|
+
const title = t('changes.inspectTitle', { file: label });
|
|
5252
|
+
if (service === undefined) {
|
|
5253
|
+
this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
|
|
5254
|
+
return;
|
|
5255
|
+
}
|
|
5256
|
+
const controller = new AbortController();
|
|
5257
|
+
void service.diff(row.sessionId, row.seq, index, controller.signal)
|
|
5258
|
+
.then(diff => {
|
|
5259
|
+
const lines = renderChangesDiff(diff);
|
|
5260
|
+
this.openChangesInspectLines(title, lines.length > 0
|
|
5261
|
+
? lines
|
|
5262
|
+
: [{ kind: 'tool-result', text: t('changes.unavailable') }]);
|
|
5263
|
+
})
|
|
5264
|
+
.catch(() => {
|
|
5265
|
+
this.openChangesInspectLines(title, [{ kind: 'tool-result', text: t('changes.unavailable') }]);
|
|
5266
|
+
});
|
|
5267
|
+
}
|
|
5268
|
+
/** Open (or replace) the changes overlay, or echo it to the log in line mode. */
|
|
5269
|
+
openChangesInspectLines(title, lines) {
|
|
5270
|
+
if (this.echoInspectToLog(title, lines))
|
|
5271
|
+
return;
|
|
5272
|
+
const dialog = this.dialog;
|
|
5273
|
+
if (dialog?.kind === 'inspect') {
|
|
5274
|
+
dialog.title = title;
|
|
5275
|
+
dialog.lines = lines;
|
|
5276
|
+
dialog.offset = 0;
|
|
5277
|
+
// The dialog may be a leftover from another overlay, and this path is
|
|
5278
|
+
// asynchronous: a reply overlay opened while the diff was being read would
|
|
5279
|
+
// keep its `copyText`, so the copy key would hand back the reply under a
|
|
5280
|
+
// diff. Everything the previous body owned is replaced here.
|
|
5281
|
+
dialog.copyText = inspectCopyText(lines);
|
|
5282
|
+
dialog.notice = undefined;
|
|
5283
|
+
this.markDirty();
|
|
5284
|
+
return;
|
|
5285
|
+
}
|
|
5286
|
+
this.openDialog({ kind: 'inspect', title, lines, offset: 0, copyText: inspectCopyText(lines) });
|
|
5287
|
+
}
|
|
4919
5288
|
/** Plan-mode / command / team events that plugins merge into SessionEventMap. */
|
|
4920
5289
|
handleExtensionEvent(event) {
|
|
4921
5290
|
const type = String(event.type);
|
|
@@ -5418,8 +5787,6 @@ export class SshTui {
|
|
|
5418
5787
|
const row = this.findSubagentRow(String(sessionId));
|
|
5419
5788
|
if (row === undefined)
|
|
5420
5789
|
return;
|
|
5421
|
-
if (sessionEventType(event) === 'assistant/chunk')
|
|
5422
|
-
return;
|
|
5423
5790
|
switch (event.type) {
|
|
5424
5791
|
case 'user/message': {
|
|
5425
5792
|
const text = collectText(event.data.content);
|
|
@@ -5450,7 +5817,7 @@ export class SshTui {
|
|
|
5450
5817
|
break;
|
|
5451
5818
|
}
|
|
5452
5819
|
case 'tool/result': {
|
|
5453
|
-
const ok = event.data.error === undefined && event.data.message
|
|
5820
|
+
const ok = event.data.error === undefined && !toolResultFailed(event.data.message);
|
|
5454
5821
|
const callId = String(event.data.message.source.callId);
|
|
5455
5822
|
const sourceName = event.data.message.source.name;
|
|
5456
5823
|
const recorded = this.toolCallNames.get(callId)
|
|
@@ -5639,6 +6006,10 @@ export class SshTui {
|
|
|
5639
6006
|
return;
|
|
5640
6007
|
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
5641
6008
|
dialog.lines = subagentInspectLines(row);
|
|
6009
|
+
// The body grows while the child runs, and the copy text is that body: it
|
|
6010
|
+
// has to move with it or the key hands back the log as it stood when the
|
|
6011
|
+
// overlay opened.
|
|
6012
|
+
dialog.copyText = inspectCopyText(dialog.lines);
|
|
5642
6013
|
}
|
|
5643
6014
|
// ── approval and questions ──────────────────────────────────────────────
|
|
5644
6015
|
hasLiveDisplay() {
|
|
@@ -5648,6 +6019,64 @@ export class SshTui {
|
|
|
5648
6019
|
return this.displayHost?.attached === true;
|
|
5649
6020
|
return true;
|
|
5650
6021
|
}
|
|
6022
|
+
/**
|
|
6023
|
+
* Tell the absent user that a question is waiting.
|
|
6024
|
+
*
|
|
6025
|
+
* The marker file is the part that needs no configuration: it sits next to the
|
|
6026
|
+
* session's stderr log, so logging back into the jump host shows it before the
|
|
6027
|
+
* TUI is open. The command is whatever the user pointed `ssh-tui.notify` at,
|
|
6028
|
+
* run once; it never blocks the wait and never reports its own failure.
|
|
6029
|
+
*/
|
|
6030
|
+
announceWaitingQuestion(request) {
|
|
6031
|
+
const sessionId = String(this.agent.id);
|
|
6032
|
+
const question = request.questions
|
|
6033
|
+
.map(item => item.question.trim())
|
|
6034
|
+
.filter(text => text !== '')
|
|
6035
|
+
.join(' / ')
|
|
6036
|
+
.slice(0, 200);
|
|
6037
|
+
const since = this.questionWaitSince ?? Date.now();
|
|
6038
|
+
void writeWaitingMarker({
|
|
6039
|
+
sessionId,
|
|
6040
|
+
count: this.queuedQuestions,
|
|
6041
|
+
question,
|
|
6042
|
+
since,
|
|
6043
|
+
});
|
|
6044
|
+
const command = this.readNotifyCommand();
|
|
6045
|
+
if (command === undefined)
|
|
6046
|
+
return;
|
|
6047
|
+
const smtp = this.readNotifySmtp();
|
|
6048
|
+
const context = {
|
|
6049
|
+
sessionId,
|
|
6050
|
+
count: this.queuedQuestions,
|
|
6051
|
+
question,
|
|
6052
|
+
waitedMs: Date.now() - since,
|
|
6053
|
+
resumeCommand: `dsh --profile ${profileFromArgv()} --resume ${sessionId}`,
|
|
6054
|
+
...(smtp.user === undefined ? {} : { smtpUser: smtp.user }),
|
|
6055
|
+
...(smtp.password === undefined ? {} : { smtpPassword: smtp.password }),
|
|
6056
|
+
};
|
|
6057
|
+
void runNotify(command, context);
|
|
6058
|
+
}
|
|
6059
|
+
/** The SMTP account `/notify smtp` saved, when the target is authenticated. */
|
|
6060
|
+
readNotifySmtp() {
|
|
6061
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
6062
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
6063
|
+
return {};
|
|
6064
|
+
const saved = raw;
|
|
6065
|
+
const user = typeof saved.notifySmtpUser === 'string' ? saved.notifySmtpUser : undefined;
|
|
6066
|
+
const password = typeof saved.notifySmtpPassword === 'string' ? saved.notifySmtpPassword : undefined;
|
|
6067
|
+
return {
|
|
6068
|
+
...(user === undefined || user === '' ? {} : { user }),
|
|
6069
|
+
...(password === undefined || password === '' ? {} : { password }),
|
|
6070
|
+
};
|
|
6071
|
+
}
|
|
6072
|
+
/** `DSH_TUI_NOTIFY`, else the saved `ssh-tui.notify`. Empty means off. */
|
|
6073
|
+
readNotifyCommand() {
|
|
6074
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
6075
|
+
const saved = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
6076
|
+
? raw.notify
|
|
6077
|
+
: undefined;
|
|
6078
|
+
return notifyCommand(process.env, typeof saved === 'string' ? saved : undefined);
|
|
6079
|
+
}
|
|
5651
6080
|
waitForLiveDisplay(signal) {
|
|
5652
6081
|
if (this.hasLiveDisplay())
|
|
5653
6082
|
return Promise.resolve();
|
|
@@ -5784,7 +6213,7 @@ export class SshTui {
|
|
|
5784
6213
|
...(request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason }),
|
|
5785
6214
|
...(this.hostSandboxMode === undefined || this.hostSandboxMode.trim() === '' ? {} : { sandboxMode: this.hostSandboxMode }),
|
|
5786
6215
|
}) }],
|
|
5787
|
-
source: { kind:
|
|
6216
|
+
source: { kind: TUI_SOURCE_KIND },
|
|
5788
6217
|
})],
|
|
5789
6218
|
system: reviewSystemPrompt(getLocale()),
|
|
5790
6219
|
maxTokens: 400,
|
|
@@ -5866,6 +6295,10 @@ export class SshTui {
|
|
|
5866
6295
|
* `the user rejected tool "bash"`. Steer a plugin notice with the real
|
|
5867
6296
|
* classifier/reviewer reason. Summary matches the workspace decision row
|
|
5868
6297
|
* so the handler does not paint the body twice.
|
|
6298
|
+
*
|
|
6299
|
+
* This notice is committed to the session, so it is exactly the injection a
|
|
6300
|
+
* V4 log refuses when it wears the released `plugin` wrapper — see
|
|
6301
|
+
* {@link TUI_SOURCE_KIND}.
|
|
5869
6302
|
*/
|
|
5870
6303
|
tellModelApprovalDenied(command, reason, summary) {
|
|
5871
6304
|
if (this.replaying || this.agentGone)
|
|
@@ -5873,7 +6306,7 @@ export class SshTui {
|
|
|
5873
6306
|
const text = t('approval.modelDenied', { command, reason });
|
|
5874
6307
|
const message = createUserMessage({
|
|
5875
6308
|
content: [{ type: 'text', text }],
|
|
5876
|
-
source: { kind:
|
|
6309
|
+
source: { kind: TUI_SOURCE_KIND, form: 'notice', summary },
|
|
5877
6310
|
});
|
|
5878
6311
|
try {
|
|
5879
6312
|
if (this.agent.status === 'running')
|
|
@@ -5983,11 +6416,20 @@ export class SshTui {
|
|
|
5983
6416
|
// Counted here, not read off the rows: the card is built only after the
|
|
5984
6417
|
// wait resolves, so at reattach time a queued question has no row yet.
|
|
5985
6418
|
this.queuedQuestions += 1;
|
|
6419
|
+
this.questionWaitSince ??= Date.now();
|
|
6420
|
+
// The user learns about the wait from a marker file and, when they
|
|
6421
|
+
// configured one, a command. Both run after the counter moves, so a
|
|
6422
|
+
// reconnect during either still sees the question as queued.
|
|
6423
|
+
this.announceWaitingQuestion(request);
|
|
5986
6424
|
try {
|
|
5987
6425
|
await this.waitForLiveDisplay(request.signal);
|
|
5988
6426
|
}
|
|
5989
6427
|
finally {
|
|
5990
6428
|
this.queuedQuestions -= 1;
|
|
6429
|
+
if (this.queuedQuestions === 0) {
|
|
6430
|
+
this.questionWaitSince = undefined;
|
|
6431
|
+
void clearWaitingMarker(String(this.agent.id));
|
|
6432
|
+
}
|
|
5991
6433
|
}
|
|
5992
6434
|
}
|
|
5993
6435
|
const answers = [];
|
|
@@ -6209,7 +6651,7 @@ export class SshTui {
|
|
|
6209
6651
|
piAiProviderProfile(provider) {
|
|
6210
6652
|
if (provider === 'deepseek-official')
|
|
6211
6653
|
return undefined;
|
|
6212
|
-
const section = this.ctx
|
|
6654
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
6213
6655
|
return section?.providers?.[provider];
|
|
6214
6656
|
}
|
|
6215
6657
|
/**
|
|
@@ -6428,7 +6870,7 @@ export class SshTui {
|
|
|
6428
6870
|
* picker costs one listing instead of one per sibling.
|
|
6429
6871
|
*/
|
|
6430
6872
|
async discoverGatewayListing(provider, signal) {
|
|
6431
|
-
const llmPiAi = this.ctx
|
|
6873
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
6432
6874
|
const profile = this.piAiProviderProfile(provider);
|
|
6433
6875
|
const source = openCodeSourceFor(provider, llmPiAi);
|
|
6434
6876
|
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
@@ -6821,7 +7263,7 @@ export class SshTui {
|
|
|
6821
7263
|
}
|
|
6822
7264
|
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
6823
7265
|
rememberedRoute(provider) {
|
|
6824
|
-
const section = this.ctx
|
|
7266
|
+
const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
|
|
6825
7267
|
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
6826
7268
|
? parseRouteMemory(section.providers)
|
|
6827
7269
|
: {};
|
|
@@ -6870,7 +7312,7 @@ export class SshTui {
|
|
|
6870
7312
|
const settings = this.ctx.get('settings');
|
|
6871
7313
|
if (settings === undefined)
|
|
6872
7314
|
return;
|
|
6873
|
-
const section =
|
|
7315
|
+
const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
|
|
6874
7316
|
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
6875
7317
|
? parseRouteMemory(section.providers)
|
|
6876
7318
|
: {};
|
|
@@ -6986,7 +7428,7 @@ export class SshTui {
|
|
|
6986
7428
|
}
|
|
6987
7429
|
if (settings !== undefined) {
|
|
6988
7430
|
try {
|
|
6989
|
-
await settings.replace(
|
|
7431
|
+
await settings.replace(AGENT_DEFAULT_MODEL_NS, {
|
|
6990
7432
|
provider: next.provider,
|
|
6991
7433
|
model: next.model,
|
|
6992
7434
|
...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
|
|
@@ -7553,6 +7995,44 @@ export class SshTui {
|
|
|
7553
7995
|
this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
|
|
7554
7996
|
this.markDirty();
|
|
7555
7997
|
}
|
|
7998
|
+
/**
|
|
7999
|
+
* /notify: where a question waiting on an absent user is announced.
|
|
8000
|
+
*
|
|
8001
|
+
* No argument shows what is configured. `off` clears it. `mail`, `smtp` and
|
|
8002
|
+
* `local` store the command that target needs; the SMTP password is kept
|
|
8003
|
+
* beside it and handed to the command through the environment, so it is never
|
|
8004
|
+
* part of the command string.
|
|
8005
|
+
*/
|
|
8006
|
+
async runNotifyCommand(arg) {
|
|
8007
|
+
const target = parseNotifyTarget(arg);
|
|
8008
|
+
if (target === undefined) {
|
|
8009
|
+
this.pushRow({ kind: 'error', text: t('notify.usage') });
|
|
8010
|
+
this.markDirty();
|
|
8011
|
+
return;
|
|
8012
|
+
}
|
|
8013
|
+
if (target.kind === 'off' && arg.trim() === '') {
|
|
8014
|
+
const current = this.readNotifyCommand();
|
|
8015
|
+
this.pushRow({
|
|
8016
|
+
kind: 'system',
|
|
8017
|
+
text: current === undefined ? t('notify.statusOff') : t('notify.statusOn', { command: current }),
|
|
8018
|
+
});
|
|
8019
|
+
this.markDirty();
|
|
8020
|
+
return;
|
|
8021
|
+
}
|
|
8022
|
+
if (target.kind === 'off') {
|
|
8023
|
+
await this.mergeUiSettings({ notify: '', notifySmtpUser: '', notifySmtpPassword: '' });
|
|
8024
|
+
this.pushRow({ kind: 'system', text: t('notify.cleared') });
|
|
8025
|
+
this.markDirty();
|
|
8026
|
+
return;
|
|
8027
|
+
}
|
|
8028
|
+
await this.mergeUiSettings({
|
|
8029
|
+
notify: notifyTargetCommand(target),
|
|
8030
|
+
notifySmtpUser: target.kind === 'smtp' ? target.user ?? '' : '',
|
|
8031
|
+
notifySmtpPassword: target.kind === 'smtp' ? target.password ?? '' : '',
|
|
8032
|
+
});
|
|
8033
|
+
this.pushRow({ kind: 'system', text: t('notify.saved', { target: notifyTargetLabel(target) }) });
|
|
8034
|
+
this.markDirty();
|
|
8035
|
+
}
|
|
7556
8036
|
/** /disconnect: pause (default) or continue the turn after SSH drop. */
|
|
7557
8037
|
async runDisconnectCommand(arg) {
|
|
7558
8038
|
const direct = parseDisconnectPolicy(arg);
|
|
@@ -7586,7 +8066,7 @@ export class SshTui {
|
|
|
7586
8066
|
}
|
|
7587
8067
|
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
7588
8068
|
async runModeCommand(arg = '') {
|
|
7589
|
-
const agentPresets = this.
|
|
8069
|
+
const agentPresets = this.presetService();
|
|
7590
8070
|
const direct = arg.trim().toLowerCase();
|
|
7591
8071
|
if (direct === 'fix' || direct === 'repair') {
|
|
7592
8072
|
await this.repairRoster();
|
|
@@ -7601,13 +8081,7 @@ export class SshTui {
|
|
|
7601
8081
|
const profile = profileFromArgv();
|
|
7602
8082
|
this.pushRow({
|
|
7603
8083
|
kind: 'error',
|
|
7604
|
-
text:
|
|
7605
|
-
t('mode.missingService'),
|
|
7606
|
-
t('mode.missingServiceHint', {
|
|
7607
|
-
profile,
|
|
7608
|
-
patch: rosterPatchPath(resolveDshHome(), profile),
|
|
7609
|
-
}),
|
|
7610
|
-
].join('\n'),
|
|
8084
|
+
text: this.missingPresetService(t('mode.missingService')),
|
|
7611
8085
|
});
|
|
7612
8086
|
this.markDirty();
|
|
7613
8087
|
return;
|
|
@@ -7667,6 +8141,13 @@ export class SshTui {
|
|
|
7667
8141
|
const selectedName = option.label;
|
|
7668
8142
|
const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
|
|
7669
8143
|
if (!hasWork) {
|
|
8144
|
+
// Feature-detected: a host whose registry predates `recompose` still
|
|
8145
|
+
// lists presets, and switching then needs a restarted session.
|
|
8146
|
+
if (typeof agentPresets.recompose !== 'function') {
|
|
8147
|
+
this.pushRow({ kind: 'error', text: t('mode.noRecompose', { id: selected.id }) });
|
|
8148
|
+
this.markDirty();
|
|
8149
|
+
return;
|
|
8150
|
+
}
|
|
7670
8151
|
await agentPresets.recompose(this.agent.ctx, selected.id);
|
|
7671
8152
|
this.presetId = selected.id;
|
|
7672
8153
|
this.presetName = selectedName;
|
|
@@ -7690,13 +8171,40 @@ export class SshTui {
|
|
|
7690
8171
|
*/
|
|
7691
8172
|
/** Re-read the roster fact this process was composed with (cheap, no IO). */
|
|
7692
8173
|
refreshRosterHealth() {
|
|
7693
|
-
|
|
8174
|
+
// 0.1.7 has no roster to be missing: what a terminal profile owns there is
|
|
8175
|
+
// the agent-plane rows, so the chip reports those instead.
|
|
8176
|
+
this.rosterMissing = this.settingsGeneration === 'forms'
|
|
8177
|
+
? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
|
|
8178
|
+
: this.ctx.get('agentPresets') === undefined;
|
|
8179
|
+
}
|
|
8180
|
+
/** Which settings protocol this host speaks; decides what "missing" means. */
|
|
8181
|
+
get settingsGeneration() {
|
|
8182
|
+
return hostSettingsGeneration(this.ctx);
|
|
8183
|
+
}
|
|
8184
|
+
/**
|
|
8185
|
+
* The two lines `/mode` and `/preset` print when the preset service is absent.
|
|
8186
|
+
*
|
|
8187
|
+
* The reason differs by host line: 0.1.5 lost a roster the profile can mount,
|
|
8188
|
+
* while 0.1.7 composes the agent process-wide and has no terminal presets at
|
|
8189
|
+
* all — the repair there is the agent-plane rows, not a roster.
|
|
8190
|
+
*/
|
|
8191
|
+
missingPresetService(first) {
|
|
8192
|
+
const profile = profileFromArgv();
|
|
8193
|
+
const patch = rosterPatchPath(resolveDshHome(), profile);
|
|
8194
|
+
return this.settingsGeneration === 'forms'
|
|
8195
|
+
? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
|
|
8196
|
+
: [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
|
|
7694
8197
|
}
|
|
7695
8198
|
async repairRoster() {
|
|
7696
8199
|
const profile = profileFromArgv();
|
|
7697
8200
|
const patch = rosterPatchPath(resolveDshHome(), profile);
|
|
7698
8201
|
try {
|
|
7699
|
-
|
|
8202
|
+
// Which rows are "the rows" is a host-line fact: 0.1.7 composes the agent
|
|
8203
|
+
// process-wide and owns the agent-plane rows instead of the roster. Both
|
|
8204
|
+
// the list and the header come from the same generation `/doctor` uses,
|
|
8205
|
+
// or this writes rows the running host cannot resolve.
|
|
8206
|
+
const generation = this.settingsGeneration;
|
|
8207
|
+
const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
|
|
7700
8208
|
this.refreshRosterHealth();
|
|
7701
8209
|
this.pushRow({
|
|
7702
8210
|
kind: 'system',
|
|
@@ -7817,7 +8325,7 @@ export class SshTui {
|
|
|
7817
8325
|
return;
|
|
7818
8326
|
}
|
|
7819
8327
|
const provider = this.currentProviderId();
|
|
7820
|
-
const llmPiAi = this.ctx
|
|
8328
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
7821
8329
|
const source = openCodeSourceFor(provider, llmPiAi);
|
|
7822
8330
|
if (source?.flavor === 'zen') {
|
|
7823
8331
|
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
@@ -7879,7 +8387,7 @@ export class SshTui {
|
|
|
7879
8387
|
* neither gets nothing.
|
|
7880
8388
|
*/
|
|
7881
8389
|
hasQuotaSurface(provider) {
|
|
7882
|
-
const llmPiAi = this.ctx
|
|
8390
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
7883
8391
|
return providerHasQuotaSurface(provider, llmPiAi);
|
|
7884
8392
|
}
|
|
7885
8393
|
async refreshQuota(options) {
|
|
@@ -7938,7 +8446,7 @@ export class SshTui {
|
|
|
7938
8446
|
const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
|
|
7939
8447
|
if (apiKey === undefined)
|
|
7940
8448
|
throw new Error(t('usage.noDeepseekKey'));
|
|
7941
|
-
const section = this.ctx
|
|
8449
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-deepseek'));
|
|
7942
8450
|
const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
|
|
7943
8451
|
? section.baseURL.trim()
|
|
7944
8452
|
: (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
|
|
@@ -8007,7 +8515,7 @@ export class SshTui {
|
|
|
8007
8515
|
return parseSuperGrokBilling(payload);
|
|
8008
8516
|
}
|
|
8009
8517
|
}
|
|
8010
|
-
const llmPiAi = this.ctx
|
|
8518
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
8011
8519
|
const commandCode = commandCodeSourceFor(provider, llmPiAi);
|
|
8012
8520
|
if (commandCode !== null)
|
|
8013
8521
|
return this.fetchCommandCodeQuota(commandCode);
|
|
@@ -8068,13 +8576,21 @@ export class SshTui {
|
|
|
8068
8576
|
// routing so a plain control byte reaches it as readily as a CSI sequence;
|
|
8069
8577
|
// everything else keeps the handling below, which is what makes the defaults
|
|
8070
8578
|
// unchanged by construction.
|
|
8071
|
-
|
|
8072
|
-
|
|
8579
|
+
const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
|
|
8580
|
+
// While a dialog is open its own routing owns the keys — with one exception:
|
|
8581
|
+
// an inspect overlay is the only place the copy key can be pressed at all,
|
|
8582
|
+
// and gating it put the reader's rebound key out of reach exactly where the
|
|
8583
|
+
// overlay's own hint advertises copying.
|
|
8584
|
+
const overlayCopy = this.dialog !== undefined
|
|
8585
|
+
&& this.dialog.kind === 'inspect'
|
|
8586
|
+
&& (this.dialog.copyText ?? '') !== ''
|
|
8587
|
+
&& moved === 'copy';
|
|
8588
|
+
if ((this.dialog === undefined || overlayCopy) && combined !== '') {
|
|
8073
8589
|
if (moved !== undefined && this.keymap.overridden.has(moved)) {
|
|
8074
8590
|
if (this.runKeyAction(moved))
|
|
8075
8591
|
return;
|
|
8076
8592
|
}
|
|
8077
|
-
if (this.keymap.suppressed.has(combined))
|
|
8593
|
+
if (this.dialog === undefined && this.keymap.suppressed.has(combined))
|
|
8078
8594
|
return;
|
|
8079
8595
|
}
|
|
8080
8596
|
const escape = /^\x1b\[([A-D])$/u;
|
|
@@ -8099,7 +8615,7 @@ export class SshTui {
|
|
|
8099
8615
|
this.markDirty();
|
|
8100
8616
|
}
|
|
8101
8617
|
else if (this.input === '' && this.collapsibleRows().length > 0) {
|
|
8102
|
-
this.
|
|
8618
|
+
this.moveFocus(-1);
|
|
8103
8619
|
}
|
|
8104
8620
|
else {
|
|
8105
8621
|
this.historyBack();
|
|
@@ -8123,7 +8639,7 @@ export class SshTui {
|
|
|
8123
8639
|
this.markDirty();
|
|
8124
8640
|
}
|
|
8125
8641
|
else if (this.input === '' && this.collapsibleRows().length > 0) {
|
|
8126
|
-
this.
|
|
8642
|
+
this.moveFocus(1);
|
|
8127
8643
|
}
|
|
8128
8644
|
else {
|
|
8129
8645
|
this.historyForward();
|
|
@@ -8151,19 +8667,23 @@ export class SshTui {
|
|
|
8151
8667
|
this.scrollInspectOrTranscript(-3);
|
|
8152
8668
|
return;
|
|
8153
8669
|
}
|
|
8154
|
-
// 32 is "motion with the left button held": a drag over a reply.
|
|
8155
|
-
|
|
8670
|
+
// 32 is "motion with the left button held": a drag over a reply. Shift
|
|
8671
|
+
// adds 4 to every button code, and a terminal that still forwards the
|
|
8672
|
+
// report while Shift is down (instead of keeping it for its own
|
|
8673
|
+
// selection) must drag exactly like an unshifted one — otherwise the
|
|
8674
|
+
// gesture the user was told to fall back on does nothing here.
|
|
8675
|
+
if (button === 32 || button === 36) {
|
|
8156
8676
|
this.extendMouseSelection(y, x);
|
|
8157
8677
|
return;
|
|
8158
8678
|
}
|
|
8159
|
-
if (button === 0) {
|
|
8679
|
+
if (button === 0 || button === 4) {
|
|
8160
8680
|
this.beginMouseSelection(y, x);
|
|
8161
8681
|
return;
|
|
8162
8682
|
}
|
|
8163
8683
|
return;
|
|
8164
8684
|
}
|
|
8165
8685
|
// A release ends either a drag (copy what it covered) or a plain click.
|
|
8166
|
-
if (button === 0)
|
|
8686
|
+
if (button === 0 || button === 4)
|
|
8167
8687
|
this.endMouseSelection(y, x);
|
|
8168
8688
|
return;
|
|
8169
8689
|
}
|
|
@@ -8396,13 +8916,20 @@ export class SshTui {
|
|
|
8396
8916
|
this.input = this.input.slice(0, this.cursor);
|
|
8397
8917
|
this.markDirty();
|
|
8398
8918
|
return;
|
|
8919
|
+
// A dialog owns the keyboard while it is up: moving the transcript cursor
|
|
8920
|
+
// under it changed what a later copy would take while the screen showed
|
|
8921
|
+
// nothing of the sort.
|
|
8399
8922
|
case '\x0e':
|
|
8400
|
-
this.
|
|
8923
|
+
if (this.dialog === undefined)
|
|
8924
|
+
this.moveFocus(1);
|
|
8401
8925
|
return;
|
|
8402
8926
|
case '\x10':
|
|
8403
|
-
this.
|
|
8927
|
+
if (this.dialog === undefined)
|
|
8928
|
+
this.moveFocus(-1);
|
|
8404
8929
|
return;
|
|
8405
8930
|
case '\x12':
|
|
8931
|
+
if (this.dialog !== undefined)
|
|
8932
|
+
return;
|
|
8406
8933
|
if (this.focusedRow === null)
|
|
8407
8934
|
this.toggleCollapsible();
|
|
8408
8935
|
else
|
|
@@ -9374,6 +9901,10 @@ export class SshTui {
|
|
|
9374
9901
|
const shadowed = shadowing !== undefined && shadowing !== '';
|
|
9375
9902
|
if (credentials !== undefined && !shadowed) {
|
|
9376
9903
|
await credentials.set(credentialRef(envRef), key);
|
|
9904
|
+
// The store writes it; we are the reason it exists. On Windows that write
|
|
9905
|
+
// ignored any mode the store passed, so the file that holds every API key
|
|
9906
|
+
// gets its ACL tightened here.
|
|
9907
|
+
await restrictPathToUser(join(dshHomeDir(), '.credentials.yaml'), { mode: 0o600 });
|
|
9377
9908
|
this.pushRow({ kind: 'system', text: t('onboard.credSaved', { env: envRef, path: displayDshPath('.credentials.yaml') }) });
|
|
9378
9909
|
return;
|
|
9379
9910
|
}
|
|
@@ -9384,13 +9915,13 @@ export class SshTui {
|
|
|
9384
9915
|
? IS_WINDOWS
|
|
9385
9916
|
? t('onboard.envShadowWin', { env: envRef })
|
|
9386
9917
|
: t('onboard.envShadowUnix', { env: envRef })
|
|
9387
|
-
: t('onboard.credMissing', { path: displayDshPath(
|
|
9918
|
+
: t('onboard.credMissing', { path: displayDshPath(envFileName()) }),
|
|
9388
9919
|
});
|
|
9389
9920
|
}
|
|
9390
9921
|
/** Write launch-environment overrides so they beat system-injected variables. */
|
|
9391
9922
|
async writeLaunchEnv(entries) {
|
|
9392
9923
|
const home = dshHomeDir();
|
|
9393
|
-
const file = join(home,
|
|
9924
|
+
const file = join(home, envFileName());
|
|
9394
9925
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
9395
9926
|
if (IS_WINDOWS) {
|
|
9396
9927
|
let previous = '';
|
|
@@ -9414,6 +9945,10 @@ export class SshTui {
|
|
|
9414
9945
|
const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
|
|
9415
9946
|
const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
|
|
9416
9947
|
await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
|
|
9948
|
+
// This file holds API keys. `mode` covers POSIX; on Windows the same
|
|
9949
|
+
// intent has to be an ACL or the keys sit under whatever the parent
|
|
9950
|
+
// directory allowed.
|
|
9951
|
+
await restrictPathToUser(file, { mode: 0o600 });
|
|
9417
9952
|
// Persist for future processes; best-effort, env.cmd remains as a manual fallback.
|
|
9418
9953
|
await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
|
|
9419
9954
|
return;
|
|
@@ -9437,6 +9972,7 @@ export class SshTui {
|
|
|
9437
9972
|
});
|
|
9438
9973
|
const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
|
|
9439
9974
|
await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
|
|
9975
|
+
await restrictPathToUser(file, { mode: 0o600 });
|
|
9440
9976
|
await this.ensurePosixEnvHook();
|
|
9441
9977
|
}
|
|
9442
9978
|
/** Persist one variable into the Windows user environment (best-effort). */
|
|
@@ -9567,7 +10103,11 @@ export class SshTui {
|
|
|
9567
10103
|
}
|
|
9568
10104
|
/** The painted lines as plain text, for the selection rules. */
|
|
9569
10105
|
plainSelectableLines() {
|
|
9570
|
-
return this.selectableLines.map(line => ({
|
|
10106
|
+
return this.selectableLines.map(line => ({
|
|
10107
|
+
text: stripAnsi(line.raw),
|
|
10108
|
+
copyable: line.copyable,
|
|
10109
|
+
gutter: line.gutter,
|
|
10110
|
+
}));
|
|
9571
10111
|
}
|
|
9572
10112
|
/** Screen row from a mouse report to an index into `selectableLines`. */
|
|
9573
10113
|
selectableLineAt(screenY) {
|
|
@@ -9692,17 +10232,48 @@ export class SshTui {
|
|
|
9692
10232
|
}
|
|
9693
10233
|
copyPlainText(text, notice) {
|
|
9694
10234
|
this.copyYank = text;
|
|
9695
|
-
|
|
10235
|
+
// The selection stays: the marker on the copied row is how the reader sees
|
|
10236
|
+
// *what* went to the clipboard, and a second press of the copy key must be
|
|
10237
|
+
// idempotent. Clearing it here made the next `/copy` silently fall back to
|
|
10238
|
+
// "the latest reply" — a different row, with nothing on screen to say so.
|
|
9696
10239
|
this.input = '';
|
|
9697
10240
|
this.cursor = 0;
|
|
9698
10241
|
this.inputFolded = false;
|
|
9699
10242
|
this.leaveHistoryBrowse();
|
|
9700
10243
|
this.write(osc52Clipboard(text));
|
|
9701
10244
|
this.pushRow({ kind: 'system', text: notice });
|
|
10245
|
+
// The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
|
|
10246
|
+
// never implemented OSC 52, conhost and screen cannot, Konsole only from
|
|
10247
|
+
// 24.12, and xterm/tmux need configuration. The write still goes out — it is
|
|
10248
|
+
// how the terminals that *do* honour it work — but where the table does not
|
|
10249
|
+
// promise it the user is told once per session rather than at every copy, or
|
|
10250
|
+
// not at all until they paste into an empty clipboard.
|
|
10251
|
+
//
|
|
10252
|
+
// An SSH session is the exception the table cannot see. The classifier reads
|
|
10253
|
+
// the *remote* terminal (the tty on the far side of the connection, which is
|
|
10254
|
+
// usually a bare Linux console or an unnamed pty), while the bytes are
|
|
10255
|
+
// written to the *local* terminal — the one the user is actually copying
|
|
10256
|
+
// into. That local terminal is what accepted the write, which is why a copy
|
|
10257
|
+
// over SSH works and then warns that it did not. Warning there is a lie, so
|
|
10258
|
+
// the caveat stays quiet inside an SSH session.
|
|
10259
|
+
//
|
|
10260
|
+
// The test is the session itself and not `paintLink`, which is also `'ssh'`
|
|
10261
|
+
// on a local window whenever the launcher relayed the frame: there the
|
|
10262
|
+
// capability table *did* describe the terminal that receives the bytes, so
|
|
10263
|
+
// the caveat is exactly what the user needs to read (the terminal probe
|
|
10264
|
+
// asserts it does, for seven terminal profiles).
|
|
10265
|
+
if (!this.terminalCaps.osc52 && !this.sshSession && !this.osc52HintShown) {
|
|
10266
|
+
this.osc52HintShown = true;
|
|
10267
|
+
this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
|
|
10268
|
+
}
|
|
9702
10269
|
this.markDirty();
|
|
9703
10270
|
}
|
|
9704
10271
|
copyFocusedCard(arg = '') {
|
|
9705
|
-
|
|
10272
|
+
// A dialog covers the input line, so the copy key is the only way to copy
|
|
10273
|
+
// from inside one. The inspect overlay carries what it is showing; every
|
|
10274
|
+
// other dialog keeps the key inert, exactly as it always was.
|
|
10275
|
+
const overlay = this.dialog?.kind === 'inspect' ? this.dialog : undefined;
|
|
10276
|
+
if (this.dialog !== undefined && overlay === undefined)
|
|
9706
10277
|
return false;
|
|
9707
10278
|
// `error` copies the newest failure or diagnostic row: a long path or a
|
|
9708
10279
|
// command in one of those must reach the clipboard whole, which is exactly
|
|
@@ -9720,14 +10291,30 @@ export class SshTui {
|
|
|
9720
10291
|
return true;
|
|
9721
10292
|
}
|
|
9722
10293
|
if (arg.trim() === '') {
|
|
9723
|
-
const
|
|
10294
|
+
const shown = overlay?.copyText;
|
|
10295
|
+
const picked = shown !== undefined && shown.trim() !== ''
|
|
10296
|
+
? { text: shown, source: 'focused' }
|
|
10297
|
+
: copyTextFromTranscript(this.rows, this.focusedRow);
|
|
9724
10298
|
if (picked.text.trim() === '') {
|
|
9725
10299
|
this.pushRow({ kind: 'system', text: t('copy.empty') });
|
|
9726
10300
|
this.markDirty();
|
|
9727
10301
|
return false;
|
|
9728
10302
|
}
|
|
9729
|
-
|
|
9730
|
-
|
|
10303
|
+
// The label follows the text: when the overlay supplied it, the row the
|
|
10304
|
+
// cursor sits on may be a different card entirely (Ctrl+N/P used to move
|
|
10305
|
+
// it behind the overlay), and naming that row would be a false report.
|
|
10306
|
+
const source = shown !== undefined && shown.trim() !== ''
|
|
10307
|
+
? t('copy.sourceFull')
|
|
10308
|
+
: picked.source === 'focused'
|
|
10309
|
+
? t(this.focusedRow?.kind === 'assistant' ? 'copy.sourceFocusedReply' : 'copy.sourceFocused')
|
|
10310
|
+
: t('copy.sourceAssistant');
|
|
10311
|
+
const notice = t('copy.ok', { chars: picked.text.length, source });
|
|
10312
|
+
this.copyPlainText(picked.text, notice);
|
|
10313
|
+
if (overlay !== undefined) {
|
|
10314
|
+
// The notice row is behind the overlay, so the overlay says it too.
|
|
10315
|
+
overlay.notice = notice;
|
|
10316
|
+
this.markDirty();
|
|
10317
|
+
}
|
|
9731
10318
|
return true;
|
|
9732
10319
|
}
|
|
9733
10320
|
this.pushRow({ kind: 'system', text: t('copy.usage') });
|
|
@@ -9777,6 +10364,14 @@ export class SshTui {
|
|
|
9777
10364
|
return;
|
|
9778
10365
|
}
|
|
9779
10366
|
this.scrollOffset = 0;
|
|
10367
|
+
// A selected reply has no card to fold, so Enter has to reach it too: the
|
|
10368
|
+
// gate below only asks about collapsible rows, and in a session with no card
|
|
10369
|
+
// at all (a plain Q&A, or right after `/clear`) that left Enter doing
|
|
10370
|
+
// nothing while `Alt+4` / Ctrl+N had just selected the reply.
|
|
10371
|
+
if (this.input.trim() === '' && this.focusedRow !== null && this.focusedRow.kind === 'assistant') {
|
|
10372
|
+
this.toggleCollapsible();
|
|
10373
|
+
return;
|
|
10374
|
+
}
|
|
9780
10375
|
if (this.input.trim() === '' && this.collapsibleRows().length > 0) {
|
|
9781
10376
|
this.toggleCollapsible();
|
|
9782
10377
|
return;
|
|
@@ -9951,6 +10546,12 @@ export class SshTui {
|
|
|
9951
10546
|
this.markDirty();
|
|
9952
10547
|
});
|
|
9953
10548
|
break;
|
|
10549
|
+
case 'notify':
|
|
10550
|
+
void this.runNotifyCommand(arg).catch((error) => {
|
|
10551
|
+
this.pushRow({ kind: 'error', text: t('cmd.failedNamed', { command: 'notify', error: errorChain(error) }) });
|
|
10552
|
+
this.markDirty();
|
|
10553
|
+
});
|
|
10554
|
+
break;
|
|
9954
10555
|
case 'disconnect':
|
|
9955
10556
|
void this.runDisconnectCommand(arg).catch((error) => {
|
|
9956
10557
|
if (error instanceof UserQuestionError) {
|
|
@@ -10388,7 +10989,16 @@ export function mountTui(ctx, config) {
|
|
|
10388
10989
|
else
|
|
10389
10990
|
process.exit(1);
|
|
10390
10991
|
};
|
|
10391
|
-
|
|
10992
|
+
// The explicit `return undefined` is deliberate, not noise: 0.1.6-alpha.2 turns
|
|
10993
|
+
// `agent/created` into a serial event whose handler must return
|
|
10994
|
+
// `undefined | Promise<undefined>`, and a bare `void` body is *not* assignable
|
|
10995
|
+
// to that (verified by compiling this file against the published 0.1.6-alpha.2
|
|
10996
|
+
// types: TS2322/TS2345). Returning `undefined` satisfies the whole 0.1.5 line
|
|
10997
|
+
// too, so the handler is ready for the next line without a compat branch.
|
|
10998
|
+
const disposeCreated = ctx.on('agent/created', ({ agent }) => {
|
|
10999
|
+
start(agent);
|
|
11000
|
+
return undefined;
|
|
11001
|
+
});
|
|
10392
11002
|
const disposeFailure = ctx.on('agent-loop/config-start-failed', ({ sessionId: failedSessionId, error }) => fail(failedSessionId, error));
|
|
10393
11003
|
const stopWaiting = () => {
|
|
10394
11004
|
disposeCreated();
|