dsh-ssh-tui 0.7.0 → 0.7.2
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 +90 -7
- package/README.md +58 -7
- package/lib/approval-cache.js +12 -9
- package/lib/approval-cache.js.map +1 -1
- package/lib/color-depth.js +26 -5
- package/lib/color-depth.js.map +1 -1
- package/lib/diag.js +60 -17
- package/lib/diag.js.map +1 -1
- package/lib/dialogs.js.map +1 -1
- package/lib/display-sock.js +68 -10
- package/lib/display-sock.js.map +1 -1
- package/lib/footer.js +35 -7
- package/lib/footer.js.map +1 -1
- package/lib/gateway-protocol.js +202 -0
- package/lib/gateway-protocol.js.map +1 -0
- package/lib/i18n/en.js +63 -41
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +63 -41
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +99 -23
- package/lib/index.js.map +1 -1
- package/lib/job-label.js +99 -17
- package/lib/job-label.js.map +1 -1
- package/lib/plan.js +269 -17
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +88 -0
- package/lib/platform.js.map +1 -0
- package/lib/provider-catalog.js +3 -0
- package/lib/provider-catalog.js.map +1 -1
- package/lib/route-memory.js +8 -1
- package/lib/route-memory.js.map +1 -1
- package/lib/session-list.js +18 -1
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +75 -23
- package/lib/session-lock.js.map +1 -1
- package/lib/session-route.js +331 -0
- package/lib/session-route.js.map +1 -0
- package/lib/subagent-model.js +79 -1
- package/lib/subagent-model.js.map +1 -1
- package/lib/term-text.js +7 -4
- package/lib/term-text.js.map +1 -1
- package/lib/tool-present.js +46 -14
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +1796 -274
- package/lib/tui.js.map +1 -1
- package/lib/types/color-depth.d.ts +4 -4
- package/lib/types/diag.d.ts +11 -0
- package/lib/types/dialogs.d.ts +4 -0
- package/lib/types/display-sock.d.ts +52 -1
- package/lib/types/footer.d.ts +18 -4
- package/lib/types/gateway-protocol.d.ts +88 -0
- package/lib/types/job-label.d.ts +38 -7
- package/lib/types/plan.d.ts +94 -4
- package/lib/types/platform.d.ts +50 -0
- package/lib/types/route-memory.d.ts +17 -22
- package/lib/types/session-list.d.ts +5 -0
- package/lib/types/session-lock.d.ts +9 -0
- package/lib/types/session-route.d.ts +191 -0
- package/lib/types/subagent-model.d.ts +53 -0
- package/lib/types/term-text.d.ts +3 -2
- package/lib/types/tool-present.d.ts +2 -17
- package/lib/types/transcript-types.d.ts +44 -1
- package/lib/types/tui.d.ts +292 -2
- package/lib/types/update-check.d.ts +42 -1
- package/lib/update-check.js +31 -2
- package/lib/update-check.js.map +1 -1
- package/package.json +1 -1
package/lib/tui.js
CHANGED
|
@@ -43,37 +43,74 @@ import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
|
43
43
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
44
44
|
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup, ROSTER_ROWS, } from './preset-rows.js';
|
|
45
45
|
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
46
|
-
import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
|
|
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 { sameSessionRoute, sessionRouteInput } from './session-route.js';
|
|
51
|
+
import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
|
|
50
52
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
51
53
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
52
54
|
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
53
55
|
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
54
56
|
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
|
|
55
|
-
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
57
|
+
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentProviderDiffers, subagentSettingsValue, } from './subagent-model.js';
|
|
56
58
|
import { resolveFreshSuperGrokToken } from './supergrok-token.js';
|
|
57
59
|
import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
|
|
60
|
+
import { displayToolName, subagentCourtesyName } from './job-label.js';
|
|
58
61
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
59
62
|
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';
|
|
60
63
|
import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
|
|
61
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';
|
|
62
65
|
import { TerminalInputGuard } from './terminal-input.js';
|
|
63
|
-
import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterChips, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerStatsGroups, formatContextPressureChip, providerHasQuotaSurface, formatStatusReport, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
|
|
66
|
+
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';
|
|
64
67
|
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';
|
|
65
|
-
import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle,
|
|
66
|
-
import { buildToolHeader, compactEditPath, compactFailureLines, compactFileStats, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
|
|
68
|
+
import { appendSubagentLog, foldSubagentUserLog, applyTurnEndToPlan, buildSubagentHeader, cardCategoryLabel, cardCategoryOf, clipSubagentActivity, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
|
|
69
|
+
import { buildToolHeader, compactEditPath, compactFailureLines, compactFileStats, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, SUBAGENT_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, wrappedToolBodyLineCount, } from './tool-present.js';
|
|
67
70
|
export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
|
|
68
71
|
export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
|
|
69
|
-
export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, providerHasQuotaSurface, quotaWindowTag, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
|
|
72
|
+
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';
|
|
70
73
|
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';
|
|
71
74
|
export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
|
|
72
|
-
export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle,
|
|
75
|
+
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';
|
|
73
76
|
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';
|
|
74
77
|
function discoverProviderModels(llm, request, signal) {
|
|
75
78
|
return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
|
|
76
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Ask a gateway which routes each of its models answers (`supported_endpoints`
|
|
82
|
+
* in an OpenAI-shaped `GET {baseURL}/models` reply). Command Code publishes
|
|
83
|
+
* them; most gateways do not, and the split then falls back to the built-in
|
|
84
|
+
* table. Best-effort by design: no key, no field, a network error or a shape we
|
|
85
|
+
* do not recognise all mean "no answer", never a failed setup.
|
|
86
|
+
*/
|
|
87
|
+
async function fetchModelEndpoints(baseURL, apiKey, signal) {
|
|
88
|
+
if (baseURL === '' || apiKey === '')
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(`${baseURL.replace(/\/+$/u, '')}/models`, {
|
|
92
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
|
|
93
|
+
// An outer signal (a picker that must not stall) wins when it is shorter.
|
|
94
|
+
signal: signal ?? AbortSignal.timeout(10_000),
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
return undefined;
|
|
98
|
+
const body = (await response.json());
|
|
99
|
+
const endpoints = new Map();
|
|
100
|
+
for (const entry of body.data ?? []) {
|
|
101
|
+
const id = typeof entry?.id === 'string' ? entry.id : '';
|
|
102
|
+
const list = Array.isArray(entry?.supported_endpoints)
|
|
103
|
+
? entry.supported_endpoints.filter((value) => typeof value === 'string')
|
|
104
|
+
: [];
|
|
105
|
+
if (id !== '' && list.length > 0)
|
|
106
|
+
endpoints.set(id, list);
|
|
107
|
+
}
|
|
108
|
+
return endpoints.size === 0 ? undefined : endpoints;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
77
114
|
function installUserQuestionAnswerer(ctx, questions, ask) {
|
|
78
115
|
const provider = questions;
|
|
79
116
|
if (typeof provider.registerProvider === 'function') {
|
|
@@ -105,20 +142,27 @@ function providerTemplates() {
|
|
|
105
142
|
defaultBaseUrl: 'https://api.deepseek.com',
|
|
106
143
|
defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
|
|
107
144
|
},
|
|
145
|
+
// One row, both routes: the wizard files each model under the protocol it
|
|
146
|
+
// speaks (the gateway publishes `supported_endpoints`, and its pinned
|
|
147
|
+
// defaults are verified on responses), so nobody has to pick a protocol by
|
|
148
|
+
// hand or discover later that half the catalogue needs the other one.
|
|
108
149
|
'opencode-go': {
|
|
109
150
|
label: t('onboard.providerGo'),
|
|
110
151
|
defaultId: 'opencode-go',
|
|
111
152
|
defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
|
|
112
153
|
api: 'openai-responses',
|
|
154
|
+
protocols: ['openai-responses', 'openai-completions', 'anthropic-messages'],
|
|
113
155
|
defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
|
114
156
|
},
|
|
115
|
-
//
|
|
116
|
-
//
|
|
157
|
+
// Same shape for Command Code: its chat route bills through the API root's
|
|
158
|
+
// `/alpha/billing/credits`, and its catalogue is split across three
|
|
159
|
+
// protocols, which the splitter reads off `GET /provider/v1/models`.
|
|
117
160
|
'command-code': {
|
|
118
161
|
label: t('onboard.providerCommandCode'),
|
|
119
162
|
defaultId: 'command-code',
|
|
120
163
|
defaultBaseUrl: 'https://api.commandcode.ai/provider/v1',
|
|
121
|
-
api: 'openai-
|
|
164
|
+
api: 'openai-responses',
|
|
165
|
+
protocols: ['openai-responses', 'openai-completions', 'anthropic-messages'],
|
|
122
166
|
defaultModels: ['deepseek/deepseek-v4.1-flash'],
|
|
123
167
|
defaultModelCapacity: { 'deepseek/deepseek-v4.1-flash': { contextWindow: 1_048_576 } },
|
|
124
168
|
},
|
|
@@ -160,6 +204,23 @@ function onboardTemplate(state) {
|
|
|
160
204
|
}
|
|
161
205
|
return providerTemplates()[state.providerType];
|
|
162
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The wizard steps that choose models, however they are presented: the id
|
|
209
|
+
* prompt, the multi-select picker, and the session-model pick.
|
|
210
|
+
*/
|
|
211
|
+
function isModelsStep(step) {
|
|
212
|
+
return step === 'models' || step === 'models-pick' || step === 'model-default';
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* How long a compaction may claim to be running before it is treated as
|
|
216
|
+
* abandoned. A real one finishes in seconds to a couple of minutes; the Host
|
|
217
|
+
* dying mid-compaction leaves `compaction/start` in the log with no end, and
|
|
218
|
+
* that row would otherwise keep the footer on 压缩中 and refuse /compact for
|
|
219
|
+
* the rest of the session's life.
|
|
220
|
+
*/
|
|
221
|
+
const COMPACTION_STALE_MS = 15 * 60_000;
|
|
222
|
+
/** How many `<family>\0<model>` → row decisions are remembered. */
|
|
223
|
+
const FAMILY_OWNER_CACHE_MAX = 512;
|
|
163
224
|
/**
|
|
164
225
|
* A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
|
|
165
226
|
* Locale-neutral on purpose — it reads the same in both catalogs.
|
|
@@ -204,6 +265,17 @@ const PLUGIN_VERSION = (() => {
|
|
|
204
265
|
})();
|
|
205
266
|
/** Files named on a collapsed compact burst before the rest are counted. */
|
|
206
267
|
const MAX_COMPACT_FILE_STATS = 4;
|
|
268
|
+
/** Lines of an inspect body that line mode writes into the log before trimming. */
|
|
269
|
+
const LINE_MODE_INSPECT_LINES = 80;
|
|
270
|
+
/**
|
|
271
|
+
* Emphasis inside a filled diff row: a lighter shade of the row's own fill, so
|
|
272
|
+
* a changed word is marked without stepping outside the muted palette. The
|
|
273
|
+
* fills they lighten live in `styleLine` (`diff-add` / `diff-del`).
|
|
274
|
+
*/
|
|
275
|
+
const DIFF_EMPHASIS_SGR = {
|
|
276
|
+
'diff-add': '38;2;198;232;190;48;2;34;72;44',
|
|
277
|
+
'diff-del': '38;2;246;206;206;48;2;86;34;34',
|
|
278
|
+
};
|
|
207
279
|
const STALL_WARNING_MS = 60000;
|
|
208
280
|
/** Bytes already queued for the terminal before a frame is skipped instead. */
|
|
209
281
|
const STDOUT_BACKLOG_BYTES = 32 * 1024;
|
|
@@ -478,6 +550,22 @@ export function parseWorkspaceView(raw) {
|
|
|
478
550
|
return undefined;
|
|
479
551
|
}
|
|
480
552
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
553
|
+
/** One line naming the route a resumed session came back on. */
|
|
554
|
+
function sessionRouteNotice(route) {
|
|
555
|
+
const effort = route.reasoningEffort === undefined || route.reasoningEffort === ''
|
|
556
|
+
? ''
|
|
557
|
+
: ` (${route.reasoningEffort})`;
|
|
558
|
+
const subagent = route.subagent;
|
|
559
|
+
const sub = subagent === undefined
|
|
560
|
+
? ''
|
|
561
|
+
: t('session.routeSubagent', {
|
|
562
|
+
route: `${subagent.provider === undefined ? '' : `${subagent.provider}/`}${subagent.model}`
|
|
563
|
+
+ (subagent.reasoningEffort === undefined || subagent.reasoningEffort === ''
|
|
564
|
+
? ''
|
|
565
|
+
: `(${subagent.reasoningEffort})`),
|
|
566
|
+
});
|
|
567
|
+
return t('session.routeRestored', { route: `${route.provider}/${route.model}${effort}`, sub });
|
|
568
|
+
}
|
|
481
569
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
482
570
|
export class SshTui {
|
|
483
571
|
ctx;
|
|
@@ -500,6 +588,19 @@ export class SshTui {
|
|
|
500
588
|
catalogLoad;
|
|
501
589
|
/** Memoized id→window index over {@link catalogPresets}. */
|
|
502
590
|
catalogWindows;
|
|
591
|
+
/** Wizard drafts whose models step is already asking the gateway, so a
|
|
592
|
+
* second Enter cannot start a duplicate listing. */
|
|
593
|
+
finishingModels = new WeakSet();
|
|
594
|
+
/** `<base>\0<model>` → the family row last seen serving that model. */
|
|
595
|
+
/**
|
|
596
|
+
* `<base>\0<model>` → the family row last seen serving that model.
|
|
597
|
+
*
|
|
598
|
+
* Bounded: a gateway can publish hundreds of models and their rows are
|
|
599
|
+
* re-learned on every listing, so the oldest entries are dropped once the map
|
|
600
|
+
* passes FAMILY_OWNER_CACHE_MAX rather than growing for the life of the
|
|
601
|
+
* session. Losing an entry only costs one future "which row?" lookup.
|
|
602
|
+
*/
|
|
603
|
+
familyModelOwner = new Map();
|
|
503
604
|
input = '';
|
|
504
605
|
cursor = 0;
|
|
505
606
|
inputFolded = false;
|
|
@@ -545,10 +646,13 @@ export class SshTui {
|
|
|
545
646
|
selectionRef;
|
|
546
647
|
subagentSelection;
|
|
547
648
|
onSelectionChanged;
|
|
649
|
+
onRouteSettled;
|
|
548
650
|
disposers = [];
|
|
549
651
|
userQuestionDisposer;
|
|
550
652
|
presetId = 'standard';
|
|
551
653
|
presetName = t('mode.preset.standard');
|
|
654
|
+
/** Last route reported to the launcher; a repaint must not report it again. */
|
|
655
|
+
lastSessionRoute;
|
|
552
656
|
useAlternateScreen;
|
|
553
657
|
agentGone = false;
|
|
554
658
|
onboarding;
|
|
@@ -564,6 +668,8 @@ export class SshTui {
|
|
|
564
668
|
seenCommandDoneIds = new Set();
|
|
565
669
|
activeSubagents = new Map();
|
|
566
670
|
subagentSessions = new Set();
|
|
671
|
+
/** Parent `subagent` tool descriptions waiting for the matching child card. */
|
|
672
|
+
pendingSubagentTasks = [];
|
|
567
673
|
openToolCalls = new Map();
|
|
568
674
|
/** Survives result settlement so a card-less result can still be labelled. */
|
|
569
675
|
toolCallNames = new Map();
|
|
@@ -711,6 +817,7 @@ export class SshTui {
|
|
|
711
817
|
// 8-colour terminal is not a cosmetic loss, it is the wrong colour.
|
|
712
818
|
this.colorDepth = config.color === false ? 'none' : colorDepth(process.env);
|
|
713
819
|
this.color = this.colorDepth !== 'none';
|
|
820
|
+
this.lastSessionRoute = config.restoredRoute;
|
|
714
821
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
715
822
|
this.showReasoning = config.showReasoning !== false;
|
|
716
823
|
this.workspaceView = this.readWorkspaceView();
|
|
@@ -723,6 +830,7 @@ export class SshTui {
|
|
|
723
830
|
this.selectionRef = config.selectionRef;
|
|
724
831
|
this.subagentSelection = config.subagentSelection ?? { current: { model: DEFAULT_SUBAGENT_MODEL } };
|
|
725
832
|
this.onSelectionChanged = config.onSelectionChanged;
|
|
833
|
+
this.onRouteSettled = config.onRouteSettled;
|
|
726
834
|
this.onHangup = config.onHangup;
|
|
727
835
|
this.onReattach = config.onReattach;
|
|
728
836
|
this.headlessDisplay = config.headlessDisplay === true;
|
|
@@ -758,6 +866,12 @@ export class SshTui {
|
|
|
758
866
|
if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
|
|
759
867
|
this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
|
|
760
868
|
}
|
|
869
|
+
if (config.restoredRoute !== undefined) {
|
|
870
|
+
this.pushRow({ kind: 'system', text: sessionRouteNotice(config.restoredRoute) });
|
|
871
|
+
}
|
|
872
|
+
for (const notice of config.launchNotices ?? []) {
|
|
873
|
+
this.pushRow({ kind: notice.kind, text: notice.text });
|
|
874
|
+
}
|
|
761
875
|
}
|
|
762
876
|
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
763
877
|
start() {
|
|
@@ -772,7 +886,12 @@ export class SshTui {
|
|
|
772
886
|
});
|
|
773
887
|
if (this.headlessDisplay) {
|
|
774
888
|
this.displayDetached = true;
|
|
775
|
-
|
|
889
|
+
// Line mode has no frames and no window title. The Host still listens on
|
|
890
|
+
// the display socket; typing arrives as FRAME_STDIN, not process.stdin.
|
|
891
|
+
// Starting the paint timer here is what leaked OSC titles into the log
|
|
892
|
+
// (`\x1b]0;dsh …\x07`) the moment a turn began.
|
|
893
|
+
if (!this.lineMode)
|
|
894
|
+
this.startRenderTimer();
|
|
776
895
|
this.bootBackgroundTasks();
|
|
777
896
|
return;
|
|
778
897
|
}
|
|
@@ -787,10 +906,8 @@ export class SshTui {
|
|
|
787
906
|
process.stdout.on('error', this.handleIoError);
|
|
788
907
|
process.stdin.on('error', this.handleIoError);
|
|
789
908
|
if (this.lineMode) {
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
process.stdin.setRawMode(true);
|
|
793
|
-
process.stdin.resume();
|
|
909
|
+
// Direct (non-Host) line mode: typing still works, but there is no
|
|
910
|
+
// screen to paint and no animation to run.
|
|
794
911
|
process.stdin.on('data', this.handleData);
|
|
795
912
|
this.bootBackgroundTasks();
|
|
796
913
|
return;
|
|
@@ -1072,8 +1189,8 @@ export class SshTui {
|
|
|
1072
1189
|
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
1073
1190
|
const styled = headerSegments.length === 0
|
|
1074
1191
|
? this.styleLine('tool', collapsed)
|
|
1075
|
-
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
1076
|
-
addDisplay(this.focusedRow === anchor
|
|
1192
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
1193
|
+
addDisplay(this.selectLine(styled, this.focusedRow === anchor), anchor);
|
|
1077
1194
|
// A collapsed burst is all the reader sees, so the two things that decide
|
|
1078
1195
|
// whether to expand belong on it: which files moved, and what failed.
|
|
1079
1196
|
if (kind === 'edits') {
|
|
@@ -1094,9 +1211,9 @@ export class SshTui {
|
|
|
1094
1211
|
}
|
|
1095
1212
|
const expandedHeaderLines = headerSegments.length === 0
|
|
1096
1213
|
? wrap(header.plain, width).map(line => this.styleLine('tool', line))
|
|
1097
|
-
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
1214
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
|
|
1098
1215
|
for (const wrapped of expandedHeaderLines) {
|
|
1099
|
-
addDisplay(this.focusedRow === anchor
|
|
1216
|
+
addDisplay(this.selectLine(wrapped, this.focusedRow === anchor), anchor);
|
|
1100
1217
|
}
|
|
1101
1218
|
for (const item of items) {
|
|
1102
1219
|
if (kind === 'edits') {
|
|
@@ -1132,7 +1249,7 @@ export class SshTui {
|
|
|
1132
1249
|
|| (row.kind === 'question' && row.status === 'waiting')
|
|
1133
1250
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
1134
1251
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
1135
|
-
|| (row.kind === 'compaction' &&
|
|
1252
|
+
|| (row.kind === 'compaction' && this.compactionRunning()));
|
|
1136
1253
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1137
1254
|
this.dirty = true;
|
|
1138
1255
|
}
|
|
@@ -1186,6 +1303,14 @@ export class SshTui {
|
|
|
1186
1303
|
finally {
|
|
1187
1304
|
this.replaying = false;
|
|
1188
1305
|
this.replayQueue = undefined;
|
|
1306
|
+
// Replay synthesizes chips from parent spawn tools; it never sees
|
|
1307
|
+
// `subagent/start`, so those descriptions must not sit in the live queue
|
|
1308
|
+
// and become the next child's courtesy name after --resume.
|
|
1309
|
+
this.pendingSubagentTasks = [];
|
|
1310
|
+
this.settleUnfinishedReplayChips();
|
|
1311
|
+
// A compaction whose start has no end anywhere in the durable log was
|
|
1312
|
+
// abandoned by the Host; nothing more can arrive for it.
|
|
1313
|
+
this.settleUnfinishedCompactions();
|
|
1189
1314
|
}
|
|
1190
1315
|
for (const item of parked) {
|
|
1191
1316
|
if (this.disposed)
|
|
@@ -1200,6 +1325,36 @@ export class SshTui {
|
|
|
1200
1325
|
this.paintTailBudget = Math.max(24, this.screenRows() * 3);
|
|
1201
1326
|
this.dirty = true;
|
|
1202
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Close the chips replay could not settle.
|
|
1330
|
+
*
|
|
1331
|
+
* A spawn `tool/call` with no `tool/result` in the log means the wrapper was
|
|
1332
|
+
* still in flight when the log was last written. Replay only runs in a Host
|
|
1333
|
+
* that has just started, and the child lived in the Host running back then,
|
|
1334
|
+
* so that child is gone and no `subagent/end` will ever arrive for it.
|
|
1335
|
+
* Leaving the chip "running" made every resume of an interrupted turn show a
|
|
1336
|
+
* child counting up forever while `/status` reported none.
|
|
1337
|
+
*
|
|
1338
|
+
* `stopReason: 'unknown'` is also what lets a late live end adopt this row
|
|
1339
|
+
* instead of adding a twin beside it.
|
|
1340
|
+
*/
|
|
1341
|
+
settleUnfinishedReplayChips() {
|
|
1342
|
+
if (this.disposed)
|
|
1343
|
+
return;
|
|
1344
|
+
for (const row of this.rows) {
|
|
1345
|
+
if (row.kind !== 'subagent')
|
|
1346
|
+
continue;
|
|
1347
|
+
if (row.status !== 'running' || row.childSessionId !== undefined)
|
|
1348
|
+
continue;
|
|
1349
|
+
row.status = 'aborted';
|
|
1350
|
+
row.endedAt = row.startedAt;
|
|
1351
|
+
row.stopReason = 'unknown';
|
|
1352
|
+
const text = t('sub.endedMissing');
|
|
1353
|
+
row.lastActivity = text;
|
|
1354
|
+
appendSubagentLog(row, { kind: 'system', text });
|
|
1355
|
+
this.refreshOpenSubagentInspect(row);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1203
1358
|
/** Show the first-launch provider/API-key onboarding when nothing is configured. */
|
|
1204
1359
|
async maybeRunOnboarding() {
|
|
1205
1360
|
const credentials = this.ctx.get('credentials');
|
|
@@ -1269,6 +1424,9 @@ export class SshTui {
|
|
|
1269
1424
|
baseUrl: '',
|
|
1270
1425
|
key: '',
|
|
1271
1426
|
models: [],
|
|
1427
|
+
modelCandidates: [],
|
|
1428
|
+
modelChecked: new Set(),
|
|
1429
|
+
modelCursor: 0,
|
|
1272
1430
|
modelCapacity: new Map(),
|
|
1273
1431
|
catalogPresets: undefined,
|
|
1274
1432
|
catalog: undefined,
|
|
@@ -1344,18 +1502,24 @@ export class SshTui {
|
|
|
1344
1502
|
catch {
|
|
1345
1503
|
// ignore
|
|
1346
1504
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1505
|
+
// Line mode never entered the alternate screen or hid the cursor; writing
|
|
1506
|
+
// the restore sequence here would dump CSI into a `tee` the moment SSH
|
|
1507
|
+
// dropped. The Host's stdout is usually a discarded pipe, but the same
|
|
1508
|
+
// path is shared with a direct (non-Host) line-mode process.
|
|
1509
|
+
if (!this.lineMode) {
|
|
1510
|
+
try {
|
|
1511
|
+
process.stdout.write('\x1b]0;\x07');
|
|
1512
|
+
process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
1513
|
+
process.stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
if (!isHangupErrno(error)) {
|
|
1517
|
+
try {
|
|
1518
|
+
process.stderr.write(`dsh-ssh-tui: failed to restore terminal: ${errorChain(error)}\n`);
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
// both pipes gone
|
|
1522
|
+
}
|
|
1359
1523
|
}
|
|
1360
1524
|
}
|
|
1361
1525
|
}
|
|
@@ -1422,6 +1586,14 @@ export class SshTui {
|
|
|
1422
1586
|
probeState: this.paintProbed ? 'measured' : this.paintLink === 'ssh' ? 'unknown' : 'unprobed',
|
|
1423
1587
|
},
|
|
1424
1588
|
...(this.paintIntervalMs === undefined ? {} : { paintIntervalMs: this.paintIntervalMs }),
|
|
1589
|
+
// The depth this process actually paints with, plus the hints behind it:
|
|
1590
|
+
// on Windows TERM is normally unset, which used to read as "no colour".
|
|
1591
|
+
color: {
|
|
1592
|
+
depth: this.colorDepth,
|
|
1593
|
+
...((process.env.TERM ?? '') === '' ? {} : { term: process.env.TERM }),
|
|
1594
|
+
...((process.env.COLORTERM ?? '') === '' ? {} : { colorTerm: process.env.COLORTERM }),
|
|
1595
|
+
windowsTerminal: (process.env.WT_SESSION ?? '') !== '',
|
|
1596
|
+
},
|
|
1425
1597
|
});
|
|
1426
1598
|
this.pushRow({ kind: 'diag', text: formatDiag(snapshot).join('\n') });
|
|
1427
1599
|
this.markDirty();
|
|
@@ -1453,7 +1625,7 @@ export class SshTui {
|
|
|
1453
1625
|
...(model === undefined ? {} : { model }),
|
|
1454
1626
|
routes: Object.keys(providers),
|
|
1455
1627
|
routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
|
|
1456
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
1628
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
1457
1629
|
...(sub.model === undefined ? {} : { subModel: sub.model }),
|
|
1458
1630
|
};
|
|
1459
1631
|
}
|
|
@@ -1594,7 +1766,7 @@ export class SshTui {
|
|
|
1594
1766
|
return true;
|
|
1595
1767
|
if (this.llmRetry !== undefined)
|
|
1596
1768
|
return true;
|
|
1597
|
-
if (this.
|
|
1769
|
+
if (this.compactionRunning())
|
|
1598
1770
|
return true;
|
|
1599
1771
|
return false;
|
|
1600
1772
|
}
|
|
@@ -1920,7 +2092,8 @@ export class SshTui {
|
|
|
1920
2092
|
this.paintRttMs = rttMs;
|
|
1921
2093
|
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
1922
2094
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
|
|
1923
|
-
this.
|
|
2095
|
+
if (!this.lineMode)
|
|
2096
|
+
this.startRenderTimer();
|
|
1924
2097
|
}
|
|
1925
2098
|
this.markDirty();
|
|
1926
2099
|
}
|
|
@@ -2034,7 +2207,7 @@ export class SshTui {
|
|
|
2034
2207
|
return false;
|
|
2035
2208
|
if (this.streaming?.text)
|
|
2036
2209
|
return false;
|
|
2037
|
-
if (this.
|
|
2210
|
+
if (this.compactionRunning())
|
|
2038
2211
|
return false;
|
|
2039
2212
|
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
2040
2213
|
return false;
|
|
@@ -2053,12 +2226,25 @@ export class SshTui {
|
|
|
2053
2226
|
...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
|
|
2054
2227
|
...(liveTool !== undefined || liveSub === undefined
|
|
2055
2228
|
? {}
|
|
2056
|
-
: { toolTitle: liveSub
|
|
2229
|
+
: { toolTitle: subagentDisplayName(liveSub), toolSummary: subagentChipSummary(liveSub) }),
|
|
2057
2230
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
2058
2231
|
};
|
|
2059
2232
|
}
|
|
2060
2233
|
findSubagentRow(sessionId) {
|
|
2061
|
-
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
2234
|
+
return this.rows.findLast((row) => row.kind === 'subagent' && (row.sessionId === sessionId || row.childSessionId === sessionId));
|
|
2235
|
+
}
|
|
2236
|
+
subagentInspectId(row) {
|
|
2237
|
+
return row.childSessionId ?? row.sessionId;
|
|
2238
|
+
}
|
|
2239
|
+
/** Courtesy name for a child session; never the raw session hash. */
|
|
2240
|
+
subagentNameFor(sessionId) {
|
|
2241
|
+
const id = sessionId === undefined ? '' : String(sessionId);
|
|
2242
|
+
if (id === '' || id === String(this.agent.id))
|
|
2243
|
+
return t('approval.thisSession');
|
|
2244
|
+
const row = this.findSubagentRow(id);
|
|
2245
|
+
if (row !== undefined)
|
|
2246
|
+
return subagentDisplayName(row);
|
|
2247
|
+
return subagentCourtesyName({ sessionId: id, fallback: t('card.subagent') });
|
|
2062
2248
|
}
|
|
2063
2249
|
findLivePlanRow() {
|
|
2064
2250
|
return findLivePlanRow(this.rows);
|
|
@@ -2246,7 +2432,7 @@ export class SshTui {
|
|
|
2246
2432
|
return;
|
|
2247
2433
|
const body = buffer
|
|
2248
2434
|
.map(piece => (piece.emphasis
|
|
2249
|
-
?
|
|
2435
|
+
? this.styleEmphasisedPiece(line.kind, piece.text)
|
|
2250
2436
|
: this.styleLine(line.kind, piece.text)))
|
|
2251
2437
|
.join('');
|
|
2252
2438
|
rows.push(` ${body}`);
|
|
@@ -2268,9 +2454,59 @@ export class SshTui {
|
|
|
2268
2454
|
flush();
|
|
2269
2455
|
return rows.length === 0 ? [this.styleLine(line.kind, '')] : rows;
|
|
2270
2456
|
}
|
|
2457
|
+
/**
|
|
2458
|
+
* A body row whose parts carry different roles: the two columns of a
|
|
2459
|
+
* side-by-side diff.
|
|
2460
|
+
*
|
|
2461
|
+
* No single kind fits such a row — the left column is a removal, the right an
|
|
2462
|
+
* addition — so each part is painted with its own style, the gutter keeps the
|
|
2463
|
+
* row's own (neutral) one, and a word emphasis rides inside whichever column
|
|
2464
|
+
* it belongs to. The first column takes the leading indent and the last one
|
|
2465
|
+
* runs to the end of the row, so both bars are flush.
|
|
2466
|
+
*/
|
|
2467
|
+
styleColumnedLine(line, width) {
|
|
2468
|
+
const indent = 2;
|
|
2469
|
+
const padded = padToWidth(` ${line.text}`, Math.max(1, width));
|
|
2470
|
+
const cut = (at) => Math.max(0, Math.min(padded.length, at + indent));
|
|
2471
|
+
const columns = (line.columns ?? []).map((column, index, all) => ({
|
|
2472
|
+
start: index === 0 ? 0 : cut(column.start),
|
|
2473
|
+
end: index === all.length - 1 ? padded.length : cut(column.end),
|
|
2474
|
+
kind: column.kind,
|
|
2475
|
+
}));
|
|
2476
|
+
const cuts = new Set([0, padded.length]);
|
|
2477
|
+
for (const span of line.spans ?? []) {
|
|
2478
|
+
cuts.add(cut(span.start));
|
|
2479
|
+
cuts.add(cut(span.end));
|
|
2480
|
+
}
|
|
2481
|
+
for (const column of line.columns ?? []) {
|
|
2482
|
+
cuts.add(cut(column.start));
|
|
2483
|
+
cuts.add(cut(column.end));
|
|
2484
|
+
}
|
|
2485
|
+
const ordered = [...cuts].sort((left, right) => left - right);
|
|
2486
|
+
const pieces = [];
|
|
2487
|
+
for (let at = 0; at + 1 < ordered.length; at += 1) {
|
|
2488
|
+
const from = ordered[at] ?? 0;
|
|
2489
|
+
const to = ordered[at + 1] ?? from;
|
|
2490
|
+
if (to <= from)
|
|
2491
|
+
continue;
|
|
2492
|
+
const column = columns.find(item => item.start <= from && item.end >= to);
|
|
2493
|
+
const emphasis = (line.spans ?? []).some(span => cut(span.start) <= from && cut(span.end) >= to);
|
|
2494
|
+
const text = padded.slice(from, to);
|
|
2495
|
+
const kind = column?.kind ?? line.kind;
|
|
2496
|
+
pieces.push(emphasis ? this.styleEmphasisedPiece(kind, text) : this.styleLine(kind, text));
|
|
2497
|
+
}
|
|
2498
|
+
return pieces.length === 0 ? [this.styleLine(line.kind, '')] : [pieces.join('')];
|
|
2499
|
+
}
|
|
2271
2500
|
paintToolBodyLine(addDisplay, row, line, width) {
|
|
2272
2501
|
const inner = Math.max(1, width - 2);
|
|
2273
2502
|
const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
|
|
2503
|
+
// A line that holds two roles (the columns of a side-by-side diff) is
|
|
2504
|
+
// painted part by part; its emphasis rides inside whichever part it is in.
|
|
2505
|
+
if (line.columns !== undefined && line.columns.length > 0) {
|
|
2506
|
+
for (const painted of this.styleColumnedLine(line, width))
|
|
2507
|
+
addDisplay(painted, row);
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2274
2510
|
// An emphasised line is styled in parts: the escapes wrap *styled* pieces,
|
|
2275
2511
|
// because the painter's sanitiser strips any escape that reaches it inside
|
|
2276
2512
|
// the text — which is how a literal `[7m` once appeared on screen.
|
|
@@ -2304,7 +2540,7 @@ export class SshTui {
|
|
|
2304
2540
|
const dialog = this.dialog;
|
|
2305
2541
|
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
2306
2542
|
return;
|
|
2307
|
-
const header = this.styleLine('system', truncateToWidth(
|
|
2543
|
+
const header = this.styleLine('system', truncateToWidth(dialog.title, width));
|
|
2308
2544
|
const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
|
|
2309
2545
|
const divider = this.styleLine('system', repeatToWidth('─', width));
|
|
2310
2546
|
const bodyBudget = Math.max(1, height - 4);
|
|
@@ -2315,18 +2551,19 @@ export class SshTui {
|
|
|
2315
2551
|
for (const wrapped of wrap(line.text, inner)) {
|
|
2316
2552
|
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2317
2553
|
const kind = line.kind;
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
: kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
|
|
2321
|
-
|| kind === 'todo-failed' || kind === 'todo-skipped'
|
|
2322
|
-
? this.styleLine(kind, body)
|
|
2323
|
-
: kind === 'error'
|
|
2324
|
-
? this.styleLine('error', body)
|
|
2325
|
-
: kind === 'assistant'
|
|
2326
|
-
? this.styleLine('assistant', body)
|
|
2327
|
-
: this.styleLine('tool-result', body));
|
|
2554
|
+
const styled = this.styleLine(kind === 'subagent' ? 'subagent-header' : kind, body);
|
|
2555
|
+
rendered.push(this.markSearchRow(styled, wrapped));
|
|
2328
2556
|
}
|
|
2329
2557
|
}
|
|
2558
|
+
// `/find` reported a hit inside this body, so put it on screen: the overlay
|
|
2559
|
+
// used to open at the top and leave the reader scrolling for it. Done once,
|
|
2560
|
+
// so PgDn afterwards keeps the reader's own position.
|
|
2561
|
+
if (dialog.searchRevealed !== true && this.searchNeedle !== '') {
|
|
2562
|
+
const at = rendered.findIndex(row => searchContains(stripAnsi(row), this.searchNeedle));
|
|
2563
|
+
if (at >= 0)
|
|
2564
|
+
dialog.offset = Math.max(0, at - 1);
|
|
2565
|
+
dialog.searchRevealed = true;
|
|
2566
|
+
}
|
|
2330
2567
|
const maxOffset = Math.max(0, rendered.length - bodyBudget);
|
|
2331
2568
|
if (dialog.offset > maxOffset)
|
|
2332
2569
|
dialog.offset = maxOffset;
|
|
@@ -2361,15 +2598,67 @@ export class SshTui {
|
|
|
2361
2598
|
this.lastChromeStart = 0;
|
|
2362
2599
|
this.lastTranscriptStart = -1;
|
|
2363
2600
|
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Reverse-video (or a `»` in monochrome) on the overlay row that holds the
|
|
2603
|
+
* current `/find` needle, matching what the transcript does for the same hit.
|
|
2604
|
+
*/
|
|
2605
|
+
markSearchRow(styled, plain) {
|
|
2606
|
+
const needle = this.searchNeedle;
|
|
2607
|
+
if (needle === '' || !searchContains(plain, needle))
|
|
2608
|
+
return styled;
|
|
2609
|
+
return this.color ? highlightAnsiNeedle(styled, needle) : `» ${styled}`;
|
|
2610
|
+
}
|
|
2364
2611
|
openToolInspect(row) {
|
|
2365
2612
|
const lines = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
|
|
2613
|
+
const title = t('tool.inspectTitle', { title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}` });
|
|
2614
|
+
if (this.echoInspectToLog(title, lines))
|
|
2615
|
+
return;
|
|
2616
|
+
this.openDialog({
|
|
2617
|
+
kind: 'inspect',
|
|
2618
|
+
title,
|
|
2619
|
+
lines,
|
|
2620
|
+
offset: 0,
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
openSubagentInspect(row) {
|
|
2624
|
+
const dialog = this.dialog;
|
|
2625
|
+
if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
|
|
2626
|
+
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
2627
|
+
dialog.lines = subagentInspectLines(row);
|
|
2628
|
+
this.markDirty();
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
2632
|
+
const lines = subagentInspectLines(row);
|
|
2633
|
+
if (this.echoInspectToLog(title, lines))
|
|
2634
|
+
return;
|
|
2366
2635
|
this.openDialog({
|
|
2367
2636
|
kind: 'inspect',
|
|
2368
|
-
title
|
|
2637
|
+
title,
|
|
2369
2638
|
lines,
|
|
2370
2639
|
offset: 0,
|
|
2640
|
+
subagentSessionId: this.subagentInspectId(row),
|
|
2371
2641
|
});
|
|
2372
2642
|
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Line mode has no framed scroller, so an inspect body goes into the log.
|
|
2645
|
+
*
|
|
2646
|
+
* Opening the modal there printed nothing and then swallowed the next Enter:
|
|
2647
|
+
* an invisible dialog. The log is the only detail surface line mode has, and
|
|
2648
|
+
* writing it keeps the input free.
|
|
2649
|
+
*/
|
|
2650
|
+
echoInspectToLog(title, lines) {
|
|
2651
|
+
if (!this.lineMode)
|
|
2652
|
+
return false;
|
|
2653
|
+
const shown = lines.slice(0, LINE_MODE_INSPECT_LINES);
|
|
2654
|
+
const body = [title, ...shown.map(line => line.text)];
|
|
2655
|
+
if (lines.length > shown.length) {
|
|
2656
|
+
body.push(t('tool.bodyMoreLines', { count: lines.length - shown.length }));
|
|
2657
|
+
}
|
|
2658
|
+
this.pushRow({ kind: 'system', text: body.join('\n') });
|
|
2659
|
+
this.markDirty();
|
|
2660
|
+
return true;
|
|
2661
|
+
}
|
|
2373
2662
|
closeInspect() {
|
|
2374
2663
|
if (this.dialog?.kind !== 'inspect')
|
|
2375
2664
|
return;
|
|
@@ -2387,12 +2676,12 @@ export class SshTui {
|
|
|
2387
2676
|
if (!row.expanded) {
|
|
2388
2677
|
const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
|
|
2389
2678
|
const styled = paint(collapsed);
|
|
2390
|
-
addDisplay(
|
|
2679
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2391
2680
|
return;
|
|
2392
2681
|
}
|
|
2393
2682
|
for (const wrapped of wrap(plain, width)) {
|
|
2394
2683
|
const styled = paint(wrapped);
|
|
2395
|
-
addDisplay(
|
|
2684
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2396
2685
|
}
|
|
2397
2686
|
}
|
|
2398
2687
|
/** Move the expand/collapse focus among reasoning and tool rows. */
|
|
@@ -2426,13 +2715,31 @@ export class SshTui {
|
|
|
2426
2715
|
return;
|
|
2427
2716
|
this.toggleCard(target);
|
|
2428
2717
|
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Whether an inline tool body would be cut short.
|
|
2720
|
+
*
|
|
2721
|
+
* Two things stop a body short: it cannot fit the workspace, or the measured
|
|
2722
|
+
* link is slow enough that the paint budget trims it. Both leave the reader
|
|
2723
|
+
* looking at part of a diff under a header that counts all of it, and both
|
|
2724
|
+
* are what the overlay is for — so both answers are the overlay, and the
|
|
2725
|
+
* card's own `Enter 全览` hint stays true.
|
|
2726
|
+
*/
|
|
2727
|
+
toolBodyTruncated(target, width, height) {
|
|
2728
|
+
const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
|
|
2729
|
+
if (body.length > toolBodyLineLimit(linkQualityOf(this.paintLink, this.paintRttMs)))
|
|
2730
|
+
return true;
|
|
2731
|
+
return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
|
|
2732
|
+
}
|
|
2429
2733
|
toggleCard(target) {
|
|
2430
|
-
if (target.kind === '
|
|
2734
|
+
if (target.kind === 'subagent') {
|
|
2735
|
+
this.focusedRow = target;
|
|
2736
|
+
this.openSubagentInspect(target);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
if (target.kind === 'tool') {
|
|
2431
2740
|
const width = Math.max(10, this.screenColumns());
|
|
2432
2741
|
const height = Math.max(6, this.screenRows());
|
|
2433
|
-
|
|
2434
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2435
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2742
|
+
if (this.toolBodyTruncated(target, width, height)) {
|
|
2436
2743
|
this.focusedRow = target;
|
|
2437
2744
|
this.openToolInspect(target);
|
|
2438
2745
|
return;
|
|
@@ -2459,6 +2766,8 @@ export class SshTui {
|
|
|
2459
2766
|
const height = Math.max(6, this.screenRows());
|
|
2460
2767
|
const workspace = this.workspaceRowsFor(width, height);
|
|
2461
2768
|
for (const row of rows) {
|
|
2769
|
+
if (row.kind === 'subagent')
|
|
2770
|
+
continue;
|
|
2462
2771
|
if (row.kind === 'tool') {
|
|
2463
2772
|
const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
|
|
2464
2773
|
if (!toolBodyFitsWorkspace(bodyRows, workspace))
|
|
@@ -2511,12 +2820,15 @@ export class SshTui {
|
|
|
2511
2820
|
revealRow(row) {
|
|
2512
2821
|
if (row === undefined)
|
|
2513
2822
|
return;
|
|
2823
|
+
if (row.kind === 'subagent') {
|
|
2824
|
+
this.focusedRow = row;
|
|
2825
|
+
this.openSubagentInspect(row);
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2514
2828
|
if (row.kind === 'tool') {
|
|
2515
2829
|
const width = Math.max(10, this.screenColumns());
|
|
2516
2830
|
const height = Math.max(6, this.screenRows());
|
|
2517
|
-
|
|
2518
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2519
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2831
|
+
if (this.toolBodyTruncated(row, width, height)) {
|
|
2520
2832
|
this.focusedRow = row;
|
|
2521
2833
|
this.openToolInspect(row);
|
|
2522
2834
|
return;
|
|
@@ -2688,7 +3000,7 @@ export class SshTui {
|
|
|
2688
3000
|
const header = t('reason.done', { marker, lines }) + (row.expanded ? '' : t('card.expand'));
|
|
2689
3001
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2690
3002
|
const styled = this.styleLine('reasoning', line);
|
|
2691
|
-
addDisplay(
|
|
3003
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2692
3004
|
if (row.expanded) {
|
|
2693
3005
|
for (const wrapped of wrap(row.text, width)) {
|
|
2694
3006
|
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
@@ -2719,13 +3031,13 @@ export class SshTui {
|
|
|
2719
3031
|
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
2720
3032
|
const styled = headerSegments.length === 0
|
|
2721
3033
|
? collapsed
|
|
2722
|
-
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
2723
|
-
addDisplay(
|
|
3034
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
3035
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2724
3036
|
continue;
|
|
2725
3037
|
}
|
|
2726
3038
|
const expandedHeaderLines = headerSegments.length === 0
|
|
2727
3039
|
? wrap(header.plain, width)
|
|
2728
|
-
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
3040
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
|
|
2729
3041
|
for (const wrapped of expandedHeaderLines) {
|
|
2730
3042
|
addDisplay(wrapped, row);
|
|
2731
3043
|
}
|
|
@@ -2744,46 +3056,24 @@ export class SshTui {
|
|
|
2744
3056
|
}
|
|
2745
3057
|
if (row.kind === 'subagent') {
|
|
2746
3058
|
const running = row.status === 'running';
|
|
2747
|
-
const
|
|
2748
|
-
const
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
};
|
|
2759
|
-
const
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
provider: row.provider,
|
|
2766
|
-
external: row.local ? '' : t('sub.external'),
|
|
2767
|
-
})), row);
|
|
2768
|
-
if (row.stopReason !== undefined) {
|
|
2769
|
-
addDisplay(this.styleLine('system', t('sub.stopReason', { reason: row.stopReason })), row);
|
|
2770
|
-
}
|
|
2771
|
-
if (row.logs.length === 0) {
|
|
2772
|
-
addDisplay(this.styleLine('system', running ? t('sub.cardWait') : t('sub.cardEmpty')), row);
|
|
2773
|
-
}
|
|
2774
|
-
else {
|
|
2775
|
-
for (const entry of row.logs) {
|
|
2776
|
-
const kind = entry.kind === 'assistant'
|
|
2777
|
-
? 'assistant'
|
|
2778
|
-
: entry.kind === 'result' && row.status === 'error'
|
|
2779
|
-
? 'error'
|
|
2780
|
-
: 'system';
|
|
2781
|
-
for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
|
|
2782
|
-
addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
}
|
|
3059
|
+
const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
|
|
3060
|
+
const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
|
|
3061
|
+
const header = buildSubagentHeader({
|
|
3062
|
+
focused: this.focusedRow === row,
|
|
3063
|
+
title: subagentDisplayName(row),
|
|
3064
|
+
status: row.status,
|
|
3065
|
+
elapsedLabel,
|
|
3066
|
+
summary: subagentChipSummary(row),
|
|
3067
|
+
spinner: running ? ` ${this.spinnerFrame()}` : '',
|
|
3068
|
+
inspectHint: t('card.inspect'),
|
|
3069
|
+
foreign: subagentProviderDiffers(this.currentProviderId(), row.modelProvider),
|
|
3070
|
+
});
|
|
3071
|
+
const headerSegments = this.color ? header.segments : [];
|
|
3072
|
+
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
3073
|
+
const styled = headerSegments.length === 0
|
|
3074
|
+
? this.styleLine('subagent-header', collapsed)
|
|
3075
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
3076
|
+
addDisplay(this.selectLine(styled, this.focusedRow === row), row);
|
|
2787
3077
|
continue;
|
|
2788
3078
|
}
|
|
2789
3079
|
if (row.kind === 'plan') {
|
|
@@ -2914,7 +3204,7 @@ export class SshTui {
|
|
|
2914
3204
|
+ (elapsed > 0 ? t('reason.elapsed', { seconds: elapsed }) : '');
|
|
2915
3205
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2916
3206
|
const styled = this.styleLine('reasoning', line);
|
|
2917
|
-
addDisplay(
|
|
3207
|
+
addDisplay(this.selectLine(styled, focused), block);
|
|
2918
3208
|
if (block.expanded) {
|
|
2919
3209
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
2920
3210
|
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
@@ -3033,8 +3323,50 @@ export class SshTui {
|
|
|
3033
3323
|
addDialog(t('onboard.ctrlF'));
|
|
3034
3324
|
if (ob.providerType === 'catalog')
|
|
3035
3325
|
addDialog(t('onboard.modelsCatalogHint'));
|
|
3326
|
+
addDialog(t('onboard.modelsPickHint'));
|
|
3036
3327
|
addDialog(t('onboard.enterEsc'));
|
|
3037
3328
|
break;
|
|
3329
|
+
case 'models-pick': {
|
|
3330
|
+
const candidates = ob.modelCandidates ?? [];
|
|
3331
|
+
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3332
|
+
addDialog(t('onboard.modelsPick', { count: candidates.length }));
|
|
3333
|
+
const start = pickerWindowStart(ob.modelCursor ?? 0, candidates.length);
|
|
3334
|
+
const end = Math.min(candidates.length, start + PICKER_WINDOW);
|
|
3335
|
+
if (start > 0)
|
|
3336
|
+
addDialog(` ${t('picker.moreAbove', { count: start })}`);
|
|
3337
|
+
for (let index = start; index < end; index += 1) {
|
|
3338
|
+
const id = candidates[index];
|
|
3339
|
+
if (id === undefined)
|
|
3340
|
+
continue;
|
|
3341
|
+
const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
|
|
3342
|
+
const mark = (ob.modelChecked ?? new Set()).has(index) ? '◉' : '○';
|
|
3343
|
+
addDialog(` ${focused} ${mark} ${id}`);
|
|
3344
|
+
}
|
|
3345
|
+
if (end < candidates.length)
|
|
3346
|
+
addDialog(` ${t('picker.moreBelow', { count: candidates.length - end })}`);
|
|
3347
|
+
addDialog(t('onboard.modelsCheckHint'));
|
|
3348
|
+
break;
|
|
3349
|
+
}
|
|
3350
|
+
case 'model-default': {
|
|
3351
|
+
const checked = this.checkedOnboardingModels(ob);
|
|
3352
|
+
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3353
|
+
addDialog(t('onboard.defaultModelPick'));
|
|
3354
|
+
const start = pickerWindowStart(ob.modelCursor ?? 0, checked.length);
|
|
3355
|
+
const end = Math.min(checked.length, start + PICKER_WINDOW);
|
|
3356
|
+
if (start > 0)
|
|
3357
|
+
addDialog(` ${t('picker.moreAbove', { count: start })}`);
|
|
3358
|
+
for (let index = start; index < end; index += 1) {
|
|
3359
|
+
const id = checked[index];
|
|
3360
|
+
if (id === undefined)
|
|
3361
|
+
continue;
|
|
3362
|
+
const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
|
|
3363
|
+
addDialog(` ${focused} ○ ${id}`);
|
|
3364
|
+
}
|
|
3365
|
+
if (end < checked.length)
|
|
3366
|
+
addDialog(` ${t('picker.moreBelow', { count: checked.length - end })}`);
|
|
3367
|
+
addDialog(t('onboard.pickHint'));
|
|
3368
|
+
break;
|
|
3369
|
+
}
|
|
3038
3370
|
case 'context':
|
|
3039
3371
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3040
3372
|
addDialog(t('onboard.contextPrompt'));
|
|
@@ -3053,6 +3385,9 @@ export class SshTui {
|
|
|
3053
3385
|
api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
|
|
3054
3386
|
}));
|
|
3055
3387
|
addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
|
|
3388
|
+
if (ob.defaultModel !== undefined) {
|
|
3389
|
+
addDialog(t('onboard.confirmDefaultModel', { model: ob.defaultModel }));
|
|
3390
|
+
}
|
|
3056
3391
|
if (ob.routeContextWindow !== undefined) {
|
|
3057
3392
|
addDialog(t('onboard.confirmContext', { value: String(ob.routeContextWindow) }));
|
|
3058
3393
|
}
|
|
@@ -3150,8 +3485,8 @@ export class SshTui {
|
|
|
3150
3485
|
continue;
|
|
3151
3486
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
3152
3487
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
3153
|
-
suggestionLines.push(index === this.suggestionIndex
|
|
3154
|
-
?
|
|
3488
|
+
suggestionLines.push(index === this.suggestionIndex
|
|
3489
|
+
? this.selectLine(fitLine(line), true)
|
|
3155
3490
|
: this.styleLine('system', fitLine(line)));
|
|
3156
3491
|
}
|
|
3157
3492
|
if (suggestionEnd < this.commandSuggestions.length) {
|
|
@@ -3257,7 +3592,7 @@ export class SshTui {
|
|
|
3257
3592
|
? formatFooterBalance(this.balanceSnapshot)
|
|
3258
3593
|
: undefined;
|
|
3259
3594
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3260
|
-
const compacting = this.
|
|
3595
|
+
const compacting = this.compactionRunning();
|
|
3261
3596
|
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3262
3597
|
const sub = this.subagentSelection.current;
|
|
3263
3598
|
const footer = {
|
|
@@ -3281,7 +3616,7 @@ export class SshTui {
|
|
|
3281
3616
|
provider,
|
|
3282
3617
|
parentModel,
|
|
3283
3618
|
subModel: sub.model,
|
|
3284
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
3619
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
3285
3620
|
...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
|
|
3286
3621
|
// Quota and context pressure stay on the identity line, where they have
|
|
3287
3622
|
// always been: B-1 moved them one row up into the strip, and a user
|
|
@@ -3318,11 +3653,18 @@ export class SshTui {
|
|
|
3318
3653
|
: activity.text;
|
|
3319
3654
|
const identity = footerIdentityParts(footer);
|
|
3320
3655
|
const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
|
|
3321
|
-
//
|
|
3322
|
-
//
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3656
|
+
// Accents on an otherwise muted line: the context ring, then the `sub:`
|
|
3657
|
+
// chip. Each reset reopens mute (`90`), the same way the pre-strip footer
|
|
3658
|
+
// did. `styleLine` sanitises first, so the accents are spliced in after.
|
|
3659
|
+
let statusLine = this.styleLine('system', statusText);
|
|
3660
|
+
if (this.color && this.contextPressure !== undefined) {
|
|
3661
|
+
const ring = formatContextPressureRing(this.contextPressure.percent);
|
|
3662
|
+
statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[90m`);
|
|
3663
|
+
}
|
|
3664
|
+
if (this.color) {
|
|
3665
|
+
const chip = identity.find(part => part.startsWith('sub:')) ?? '';
|
|
3666
|
+
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || '90', this.colorDepth);
|
|
3667
|
+
}
|
|
3326
3668
|
// The strip: one loss order for the groups a narrow terminal can do without.
|
|
3327
3669
|
// Health leads because it is the only group that reports a broken install;
|
|
3328
3670
|
// the session counters go before the operational signals. Quota and context
|
|
@@ -3472,7 +3814,7 @@ export class SshTui {
|
|
|
3472
3814
|
}
|
|
3473
3815
|
currentSelectionLabel() {
|
|
3474
3816
|
const current = this.selectionRef?.current;
|
|
3475
|
-
const provider = this.currentProviderId();
|
|
3817
|
+
const provider = this.displayProviderId(this.currentProviderId());
|
|
3476
3818
|
const model = current?.model ?? this.agent.options.model ?? 'unknown';
|
|
3477
3819
|
const effort = current?.reasoningEffort;
|
|
3478
3820
|
const kind = describeProviderRoute(provider).short;
|
|
@@ -3484,7 +3826,7 @@ export class SshTui {
|
|
|
3484
3826
|
}
|
|
3485
3827
|
/** Refresh the terminal window title (throttled while running). */
|
|
3486
3828
|
updateTerminalTitle() {
|
|
3487
|
-
if (this.exiting)
|
|
3829
|
+
if (this.exiting || this.lineMode)
|
|
3488
3830
|
return;
|
|
3489
3831
|
const now = Date.now();
|
|
3490
3832
|
// Completion wins over a still-running agent status: the turn/end event
|
|
@@ -3495,16 +3837,20 @@ export class SshTui {
|
|
|
3495
3837
|
this.write(t('title.done', { suffix: titleSuffix }));
|
|
3496
3838
|
return;
|
|
3497
3839
|
}
|
|
3498
|
-
|
|
3840
|
+
// A compaction is work in progress even when it runs between turns (the
|
|
3841
|
+
// idle auto-compact), so it keeps the spinner title the way a running turn
|
|
3842
|
+
// does — the same rule the footer's activity chip follows.
|
|
3843
|
+
const compacting = this.compactionRunning();
|
|
3844
|
+
if (this.agent.status === 'running' || compacting) {
|
|
3499
3845
|
if (now - this.lastTitleUpdateAt < 800)
|
|
3500
3846
|
return;
|
|
3501
3847
|
this.lastTitleUpdateAt = now;
|
|
3502
3848
|
const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
|
|
3503
|
-
let detail = t('title.running');
|
|
3849
|
+
let detail = compacting ? t('title.compacting') : t('title.running');
|
|
3504
3850
|
if (this.dialog?.kind === 'questions') {
|
|
3505
3851
|
detail = planReviewOf(this.dialog.question) ? t('question.planTitle') : t('title.waitAnswer');
|
|
3506
3852
|
}
|
|
3507
|
-
else if (
|
|
3853
|
+
else if (compacting) {
|
|
3508
3854
|
detail = t('title.compacting');
|
|
3509
3855
|
}
|
|
3510
3856
|
else if (this.activeSubagents.size > 0) {
|
|
@@ -3530,6 +3876,8 @@ export class SshTui {
|
|
|
3530
3876
|
}
|
|
3531
3877
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3532
3878
|
playCompletionSignal() {
|
|
3879
|
+
if (this.lineMode)
|
|
3880
|
+
return;
|
|
3533
3881
|
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
3534
3882
|
if (disabled)
|
|
3535
3883
|
return;
|
|
@@ -3991,15 +4339,50 @@ export class SshTui {
|
|
|
3991
4339
|
kind === 'todo-skipped' ? '2;33' :
|
|
3992
4340
|
kind === 'todo-pending' ? '90' :
|
|
3993
4341
|
kind === 'plan-dock' ? '38;5;180' :
|
|
3994
|
-
kind === '
|
|
3995
|
-
kind === '
|
|
3996
|
-
'
|
|
4342
|
+
kind === 'subagent-header' ? '38;5;141' :
|
|
4343
|
+
kind === 'diag' ? '2;36' :
|
|
4344
|
+
kind === 'error' ? '31' :
|
|
4345
|
+
'90';
|
|
3997
4346
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
3998
4347
|
// Nothing left to paint: the text stands on its own.
|
|
3999
4348
|
if (code === '')
|
|
4000
4349
|
return safe;
|
|
4001
4350
|
return `\x1b[${code}m${safe}\x1b[0m`;
|
|
4002
4351
|
}
|
|
4352
|
+
/**
|
|
4353
|
+
* One emphasised piece of a body line — the words a diff changed.
|
|
4354
|
+
*
|
|
4355
|
+
* Inside a filled diff row the mark is a lighter shade of that same fill.
|
|
4356
|
+
* Inverse video was there first, and it inverted the *fill*: a line replaced
|
|
4357
|
+
* wholesale is emphasised from end to end, so it came out a bright block
|
|
4358
|
+
* beside rows that kept the muted tone — visibly from another palette. On a
|
|
4359
|
+
* terminal with no colour left to shift, the attribute is what survives, so
|
|
4360
|
+
* the inverse video stays for that case.
|
|
4361
|
+
*/
|
|
4362
|
+
styleEmphasisedPiece(kind, text) {
|
|
4363
|
+
const requested = DIFF_EMPHASIS_SGR[kind];
|
|
4364
|
+
if (requested !== undefined && this.color) {
|
|
4365
|
+
const code = downgradeSgr(requested, this.colorDepth);
|
|
4366
|
+
if (code !== '')
|
|
4367
|
+
return `\x1b[${code}m${sanitizeTerminalText(text)}\x1b[0m`;
|
|
4368
|
+
}
|
|
4369
|
+
return `\x1b[7m${this.styleLine(kind, text)}\x1b[27m`;
|
|
4370
|
+
}
|
|
4371
|
+
/**
|
|
4372
|
+
* Paint a row the reader has selected.
|
|
4373
|
+
*
|
|
4374
|
+
* The obvious `7`…`27` wrap only reaches the part before the line's first
|
|
4375
|
+
* reset, and every painted segment ends with one: a focused card kept its
|
|
4376
|
+
* marker and status dot highlighted while the title, the state word and the
|
|
4377
|
+
* summary stayed plain, so ↑/↓ gave no visible clue which card was picked —
|
|
4378
|
+
* on exactly the rows that carry a segmented header. Re-opening the attribute
|
|
4379
|
+
* after every reset covers the whole row, whatever colours sit on top of it.
|
|
4380
|
+
*/
|
|
4381
|
+
selectLine(styled, selected) {
|
|
4382
|
+
if (!selected || !this.color)
|
|
4383
|
+
return styled;
|
|
4384
|
+
return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
|
|
4385
|
+
}
|
|
4003
4386
|
/**
|
|
4004
4387
|
* Fold one live or durable stream chunk into the in-progress assistant
|
|
4005
4388
|
* row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
|
|
@@ -4072,6 +4455,12 @@ export class SshTui {
|
|
|
4072
4455
|
* The parent request is left untouched (its own `/model` waterfall already
|
|
4073
4456
|
* owns the route); direct children created by tool-subagent inherit the
|
|
4074
4457
|
* parent provider unless `/submodel` stored an explicit subagent provider.
|
|
4458
|
+
*
|
|
4459
|
+
* Effort is not inherited from the parent. `next()` copies the live parent
|
|
4460
|
+
* config, so a grok-4.6 `xhigh` parent would otherwise send `xhigh` with
|
|
4461
|
+
* the light grok-4.5 child and fail with UNSUPPORTED_REASONING_EFFORT
|
|
4462
|
+
* before the child writes a closing message. An explicit `/subeffort`
|
|
4463
|
+
* still wins; otherwise the child's own adapter default is used.
|
|
4075
4464
|
*/
|
|
4076
4465
|
handleAgentRequest = async ({ agent }, next) => {
|
|
4077
4466
|
const resolved = await next();
|
|
@@ -4080,14 +4469,23 @@ export class SshTui {
|
|
|
4080
4469
|
const selection = this.subagentSelection.current;
|
|
4081
4470
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
4082
4471
|
const provider = selection.provider ?? parentProvider;
|
|
4083
|
-
|
|
4472
|
+
// A pinned provider keeps the stored model even when it is a different
|
|
4473
|
+
// family from the parent. Only an inherited (follow-parent) route is
|
|
4474
|
+
// rewritten onto a same-family default.
|
|
4475
|
+
const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
|
|
4084
4476
|
? selection.model
|
|
4085
4477
|
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
4478
|
+
const { reasoningEffort: parentEffort, ...withoutParentEffort } = resolved;
|
|
4479
|
+
// `/subeffort` wins. Otherwise keep the parent effort only when the child
|
|
4480
|
+
// is still on that same model — grok-4.6 xhigh is legal on grok-4.6 and
|
|
4481
|
+
// rejected on grok-4.5.
|
|
4482
|
+
const effort = selection.reasoningEffort
|
|
4483
|
+
?? (model === resolved.model ? parentEffort : undefined);
|
|
4086
4484
|
return {
|
|
4087
|
-
...
|
|
4485
|
+
...withoutParentEffort,
|
|
4088
4486
|
provider,
|
|
4089
4487
|
model,
|
|
4090
|
-
...(
|
|
4488
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
4091
4489
|
};
|
|
4092
4490
|
};
|
|
4093
4491
|
handleSessionEvent = (session, event) => {
|
|
@@ -4217,6 +4615,30 @@ export class SshTui {
|
|
|
4217
4615
|
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
4218
4616
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
4219
4617
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4618
|
+
if (SUBAGENT_TOOL_NAMES.has(event.data.name)) {
|
|
4619
|
+
const task = present.summary.trim();
|
|
4620
|
+
const callId = String(event.data.callId);
|
|
4621
|
+
// Replay never gets `subagent/start` to drain this queue, so only a
|
|
4622
|
+
// live spawn waits for its child card. The pair is dropped again at
|
|
4623
|
+
// `tool/result` when the spawn never started a child (denied,
|
|
4624
|
+
// cancelled): a description left behind would name the next child.
|
|
4625
|
+
if (task !== '' && !this.replaying)
|
|
4626
|
+
this.pendingSubagentTasks.push({ task, callId });
|
|
4627
|
+
if (this.replaying && !this.rows.some(candidate => candidate.kind === 'subagent' && candidate.runId === callId)) {
|
|
4628
|
+
// Live `subagent/start` is not in the parent log. Resume still
|
|
4629
|
+
// needs a courtesy chip; a live Host already has the real card.
|
|
4630
|
+
this.pushRow(subagentRowFromSpawnTool({
|
|
4631
|
+
callId,
|
|
4632
|
+
task,
|
|
4633
|
+
modelProvider: this.currentProviderId(),
|
|
4634
|
+
status: 'running',
|
|
4635
|
+
startedAt: typeof event.time === 'number' ? event.time : Date.now(),
|
|
4636
|
+
}));
|
|
4637
|
+
}
|
|
4638
|
+
this.streaming = undefined;
|
|
4639
|
+
this.markDirty();
|
|
4640
|
+
break;
|
|
4641
|
+
}
|
|
4220
4642
|
const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
|
|
4221
4643
|
if (previous !== undefined) {
|
|
4222
4644
|
this.mergeIntoToolCard(previous, {
|
|
@@ -4259,8 +4681,30 @@ export class SshTui {
|
|
|
4259
4681
|
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
4260
4682
|
this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
|
|
4261
4683
|
const callId = String(event.data.message.source.callId);
|
|
4262
|
-
|
|
4684
|
+
// A spawn whose wrapper came back without starting a child (approval
|
|
4685
|
+
// denied, provider refused, turn cancelled) must not leave its
|
|
4686
|
+
// description queued: the next child would inherit that name.
|
|
4687
|
+
this.pendingSubagentTasks = this.pendingSubagentTasks.filter(entry => entry.callId !== callId);
|
|
4688
|
+
const spawnCard = this.rows.findLast((candidate) => candidate.kind === 'subagent' && (candidate.runId === callId || candidate.spawnCallId === callId));
|
|
4263
4689
|
const output = collectText(event.data.message.content);
|
|
4690
|
+
if (spawnCard !== undefined) {
|
|
4691
|
+
// A live child already owns this spawn: the parent tool/result is
|
|
4692
|
+
// just the wrapper settling. Do not stamp a 0s twin chip complete.
|
|
4693
|
+
if (spawnCard.childSessionId !== undefined) {
|
|
4694
|
+
this.markDirty();
|
|
4695
|
+
break;
|
|
4696
|
+
}
|
|
4697
|
+
const failed = event.data.error !== undefined || event.data.message.content[0]?.isError === true;
|
|
4698
|
+
spawnCard.status = failed ? 'error' : 'ok';
|
|
4699
|
+
spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
|
|
4700
|
+
if (output !== '') {
|
|
4701
|
+
spawnCard.lastActivity = clipSubagentActivity(output, 80);
|
|
4702
|
+
appendSubagentLog(spawnCard, { kind: failed ? 'result' : 'assistant', text: clipSubagentActivity(output, 80) });
|
|
4703
|
+
}
|
|
4704
|
+
this.markDirty();
|
|
4705
|
+
break;
|
|
4706
|
+
}
|
|
4707
|
+
const row = this.findToolRowByCallId(callId);
|
|
4264
4708
|
if (row !== undefined) {
|
|
4265
4709
|
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
4266
4710
|
if (metaDiffs !== null) {
|
|
@@ -4295,11 +4739,14 @@ export class SshTui {
|
|
|
4295
4739
|
?? (typeof sourceName === 'string' ? sourceName : '');
|
|
4296
4740
|
if (recordedName !== '' && HIDDEN_TOOL_NAMES.has(recordedName))
|
|
4297
4741
|
break;
|
|
4742
|
+
// A spawn is drawn as the child's chip, never as a `subagent` tool
|
|
4743
|
+
// card: without a start there is still no card to settle, and a
|
|
4744
|
+
// second card beside the chip is exactly the duplicate to avoid.
|
|
4745
|
+
if (SUBAGENT_TOOL_NAMES.has(recordedName))
|
|
4746
|
+
break;
|
|
4298
4747
|
// Never title a card with the call id (`call-<uuid>`). Prefer the
|
|
4299
4748
|
// recorded tool name; fall back to a generic tool card.
|
|
4300
|
-
const toolName = recordedName
|
|
4301
|
-
? 'tool'
|
|
4302
|
-
: recordedName;
|
|
4749
|
+
const toolName = displayToolName(recordedName);
|
|
4303
4750
|
const present = presentToolCall(toolName, '');
|
|
4304
4751
|
this.pushRow({
|
|
4305
4752
|
kind: 'tool',
|
|
@@ -4348,6 +4795,9 @@ export class SshTui {
|
|
|
4348
4795
|
this.stalledWarningShown = false;
|
|
4349
4796
|
this.llmRetry = undefined;
|
|
4350
4797
|
this.status = `turn ${event.data.turn} running`;
|
|
4798
|
+
// A turn is about to spend on this route: make sure the session's record
|
|
4799
|
+
// names it, whatever changed it (a preset, a settings edit, a resume).
|
|
4800
|
+
this.noteSessionRoute();
|
|
4351
4801
|
this.markDirty();
|
|
4352
4802
|
break;
|
|
4353
4803
|
case 'turn/end': {
|
|
@@ -4356,6 +4806,10 @@ export class SshTui {
|
|
|
4356
4806
|
this.toolCallNames.clear();
|
|
4357
4807
|
this.statsTracker.noteTurnEnd();
|
|
4358
4808
|
this.stalledWarningShown = false;
|
|
4809
|
+
// A retry belongs to the turn that scheduled it: once the turn closes,
|
|
4810
|
+
// the chip must not outlive it (a failed retry would otherwise leave
|
|
4811
|
+
// "重试 n/m" on an idle footer until the next turn).
|
|
4812
|
+
this.llmRetry = undefined;
|
|
4359
4813
|
this.pendingMessages.clear();
|
|
4360
4814
|
// Aborted/errored turns may close without an assembled
|
|
4361
4815
|
// assistant/message; never leave a half-streamed thinking block behind.
|
|
@@ -4530,6 +4984,12 @@ export class SshTui {
|
|
|
4530
4984
|
if (this.llmRetry !== undefined) {
|
|
4531
4985
|
this.pushRow({ kind: 'system', text: t('retry.started', { retry: this.llmRetry.retry }) });
|
|
4532
4986
|
}
|
|
4987
|
+
// The backoff is over and the retried request is in flight: the footer
|
|
4988
|
+
// must go back to the ordinary running/waiting states instead of holding
|
|
4989
|
+
// "retry n/m" until the next turn starts (which could be minutes later,
|
|
4990
|
+
// or never — and a compaction ending in between fell back to this stale
|
|
4991
|
+
// chip rather than to 运行中).
|
|
4992
|
+
this.llmRetry = undefined;
|
|
4533
4993
|
this.markDirty();
|
|
4534
4994
|
return;
|
|
4535
4995
|
}
|
|
@@ -4552,7 +5012,54 @@ export class SshTui {
|
|
|
4552
5012
|
if (named !== undefined)
|
|
4553
5013
|
return named;
|
|
4554
5014
|
}
|
|
4555
|
-
|
|
5015
|
+
// An event whose id matches no card still belongs to the compaction in
|
|
5016
|
+
// flight: the Host runs at most one at a time, so the newest running card is
|
|
5017
|
+
// the only candidate. Without this a mismatched (or absent) id left the row
|
|
5018
|
+
// running forever — the same stuck state an abandoned Host produces.
|
|
5019
|
+
return this.runningCompactions().at(-1);
|
|
5020
|
+
}
|
|
5021
|
+
/**
|
|
5022
|
+
* Compaction cards that can still be in flight: not finished, and within the
|
|
5023
|
+
* window a real compaction could take. A stale card is treated as not running
|
|
5024
|
+
* so it cannot hold the footer, the spinner, or `/compact` hostage.
|
|
5025
|
+
*/
|
|
5026
|
+
runningCompactions(now = Date.now()) {
|
|
5027
|
+
return this.rows.filter((row) => row.kind === 'compaction'
|
|
5028
|
+
&& row.status === 'running'
|
|
5029
|
+
&& now - row.startedAt < COMPACTION_STALE_MS);
|
|
5030
|
+
}
|
|
5031
|
+
/** Whether a compaction is genuinely in flight right now. */
|
|
5032
|
+
compactionRunning() {
|
|
5033
|
+
return this.runningCompactions().length > 0;
|
|
5034
|
+
}
|
|
5035
|
+
/**
|
|
5036
|
+
* Close compaction cards whose Host is gone.
|
|
5037
|
+
*
|
|
5038
|
+
* `compaction/start` is written before the work and `compaction/end` after it,
|
|
5039
|
+
* so a Host that exits in between leaves an open start in the durable log.
|
|
5040
|
+
* Replaying that log faithfully used to recreate a card stuck on "running"
|
|
5041
|
+
* forever: the footer said 压缩中, the spinner kept animating, and every later
|
|
5042
|
+
* `/compact` was refused as "already compacting". After a full replay nothing
|
|
5043
|
+
* more can arrive for those starts, so they are settled here.
|
|
5044
|
+
*
|
|
5045
|
+
* `staleOnly` keeps the same sweep usable while the session is live, where a
|
|
5046
|
+
* compaction that is merely slow must not be closed under it.
|
|
5047
|
+
*/
|
|
5048
|
+
settleUnfinishedCompactions(options = {}) {
|
|
5049
|
+
const now = Date.now();
|
|
5050
|
+
let settled = false;
|
|
5051
|
+
for (const row of this.rows) {
|
|
5052
|
+
if (row.kind !== 'compaction' || row.status !== 'running')
|
|
5053
|
+
continue;
|
|
5054
|
+
if (options.staleOnly === true && now - row.startedAt < COMPACTION_STALE_MS)
|
|
5055
|
+
continue;
|
|
5056
|
+
row.status = 'error';
|
|
5057
|
+
row.endedAt = row.startedAt === 0 ? now : row.startedAt;
|
|
5058
|
+
row.error = t('compact.interrupted');
|
|
5059
|
+
settled = true;
|
|
5060
|
+
}
|
|
5061
|
+
if (settled)
|
|
5062
|
+
this.markDirty();
|
|
4556
5063
|
}
|
|
4557
5064
|
handleCompactionEvent(type, event) {
|
|
4558
5065
|
const payload = event.data;
|
|
@@ -4663,11 +5170,14 @@ export class SshTui {
|
|
|
4663
5170
|
canRunCompactCommand() {
|
|
4664
5171
|
if (this.replaying || this.agentGone || this.exiting)
|
|
4665
5172
|
return false;
|
|
5173
|
+
// A card left running by a Host that died would otherwise refuse /compact
|
|
5174
|
+
// for the rest of the session; its age decides whether it is still real.
|
|
5175
|
+
this.settleUnfinishedCompactions({ staleOnly: true });
|
|
4666
5176
|
if (this.agent.status === 'running')
|
|
4667
5177
|
return false;
|
|
4668
5178
|
if (this.idleCompactInFlight)
|
|
4669
5179
|
return false;
|
|
4670
|
-
if (this.
|
|
5180
|
+
if (this.compactionRunning())
|
|
4671
5181
|
return false;
|
|
4672
5182
|
return this.ctx.get('commands') !== undefined;
|
|
4673
5183
|
}
|
|
@@ -4687,8 +5197,13 @@ export class SshTui {
|
|
|
4687
5197
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4688
5198
|
return;
|
|
4689
5199
|
}
|
|
5200
|
+
// A card left running by a Host that died is closed here rather than
|
|
5201
|
+
// refusing the user for the rest of the session: its age decides whether it
|
|
5202
|
+
// is still real. The footer already ignores a stale card, but the card
|
|
5203
|
+
// itself must stop rendering as running too.
|
|
5204
|
+
this.settleUnfinishedCompactions({ staleOnly: true });
|
|
4690
5205
|
if (this.agent.status === 'running'
|
|
4691
|
-
|| this.
|
|
5206
|
+
|| this.compactionRunning()) {
|
|
4692
5207
|
if (reason === 'user')
|
|
4693
5208
|
this.pushRow({ kind: 'error', text: t('compact.busy') });
|
|
4694
5209
|
this.markDirty();
|
|
@@ -4719,7 +5234,7 @@ export class SshTui {
|
|
|
4719
5234
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4720
5235
|
return;
|
|
4721
5236
|
}
|
|
4722
|
-
const compactionRunning = () => this.
|
|
5237
|
+
const compactionRunning = () => this.compactionRunning();
|
|
4723
5238
|
const releaseIfSettled = () => {
|
|
4724
5239
|
queueMicrotask(() => {
|
|
4725
5240
|
if (!compactionRunning())
|
|
@@ -4820,7 +5335,7 @@ export class SshTui {
|
|
|
4820
5335
|
if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
|
|
4821
5336
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
4822
5337
|
}
|
|
4823
|
-
if (!this.
|
|
5338
|
+
if (!this.compactionRunning()) {
|
|
4824
5339
|
this.idleCompactInFlight = false;
|
|
4825
5340
|
}
|
|
4826
5341
|
this.markDirty();
|
|
@@ -4908,35 +5423,72 @@ export class SshTui {
|
|
|
4908
5423
|
switch (event.type) {
|
|
4909
5424
|
case 'user/message': {
|
|
4910
5425
|
const text = collectText(event.data.content);
|
|
4911
|
-
|
|
4912
|
-
|
|
5426
|
+
const source = event.data.source;
|
|
5427
|
+
const sourceKind = typeof source?.kind === 'string' ? source.kind : 'user';
|
|
5428
|
+
const plugin = typeof source?.plugin === 'string' ? source.plugin : undefined;
|
|
5429
|
+
foldSubagentUserLog(row, text, sourceKind, plugin);
|
|
4913
5430
|
break;
|
|
4914
5431
|
}
|
|
4915
5432
|
case 'assistant/message': {
|
|
4916
5433
|
const text = collectText(event.data.message.content);
|
|
5434
|
+
// Long enough to be worth opening the overlay for; the collapsed chip
|
|
5435
|
+
// reads `lastActivity`, not this entry.
|
|
4917
5436
|
if (text !== '')
|
|
4918
|
-
appendSubagentLog(row, { kind: 'assistant', text:
|
|
5437
|
+
appendSubagentLog(row, { kind: 'assistant', text: clipSubagentActivity(text, 200) });
|
|
4919
5438
|
break;
|
|
4920
5439
|
}
|
|
4921
5440
|
case 'tool/call': {
|
|
4922
5441
|
if (HIDDEN_TOOL_NAMES.has(event.data.name))
|
|
4923
5442
|
break;
|
|
5443
|
+
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
4924
5444
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4925
|
-
appendSubagentLog(row, {
|
|
5445
|
+
appendSubagentLog(row, {
|
|
5446
|
+
kind: 'tool',
|
|
5447
|
+
text: `▶ ${present.title} ${clipSubagentActivity(present.summary, 48)}`.trimEnd(),
|
|
5448
|
+
callId: String(event.data.callId),
|
|
5449
|
+
});
|
|
4926
5450
|
break;
|
|
4927
5451
|
}
|
|
4928
5452
|
case 'tool/result': {
|
|
4929
|
-
const output = truncate(collectText(event.data.message.content), 3);
|
|
4930
5453
|
const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
|
|
5454
|
+
const callId = String(event.data.message.source.callId);
|
|
5455
|
+
const sourceName = event.data.message.source.name;
|
|
5456
|
+
const recorded = this.toolCallNames.get(callId)
|
|
5457
|
+
?? (typeof sourceName === 'string' ? sourceName : '');
|
|
5458
|
+
const title = toolTitle(displayToolName(recorded));
|
|
5459
|
+
// The overlay is the only place a child's tool output can be read, so
|
|
5460
|
+
// keep a few lines of it — the call line itself carries the title.
|
|
5461
|
+
const output = truncate(collectText(event.data.message.content), 3).trim();
|
|
4931
5462
|
appendSubagentLog(row, {
|
|
4932
5463
|
kind: 'result',
|
|
4933
|
-
text: `${ok ? '✓' : '✗'} ${
|
|
5464
|
+
text: `${ok ? '✓' : '✗'} ${title}`,
|
|
5465
|
+
...(output === '' ? {} : { detail: output }),
|
|
5466
|
+
callId,
|
|
4934
5467
|
});
|
|
4935
5468
|
break;
|
|
4936
5469
|
}
|
|
4937
|
-
case 'turn/end':
|
|
4938
|
-
|
|
5470
|
+
case 'turn/end': {
|
|
5471
|
+
const reason = event.data.reason;
|
|
5472
|
+
const error = reason.kind === 'error'
|
|
5473
|
+
? String(reason.error?.message ?? '')
|
|
5474
|
+
: '';
|
|
5475
|
+
const explained = describeSubagentFailure({
|
|
5476
|
+
stopReason: reason.kind,
|
|
5477
|
+
message: error,
|
|
5478
|
+
provider: row.modelProvider ?? this.currentProviderId(),
|
|
5479
|
+
});
|
|
5480
|
+
if (explained !== undefined)
|
|
5481
|
+
row.failHint = explained.hint;
|
|
5482
|
+
appendSubagentLog(row, {
|
|
5483
|
+
kind: error === '' ? 'turn' : 'result',
|
|
5484
|
+
text: explained === undefined
|
|
5485
|
+
? (error === ''
|
|
5486
|
+
? t('sub.turnEnd', { reason: reason.kind })
|
|
5487
|
+
: t('sub.turnEndError', { reason: reason.kind, error }))
|
|
5488
|
+
: explained.hint,
|
|
5489
|
+
});
|
|
4939
5490
|
break;
|
|
5491
|
+
}
|
|
4940
5492
|
case 'approval/asked':
|
|
4941
5493
|
appendSubagentLog(row, { kind: 'approval', text: t('sub.approval', { tool: event.data.toolName }) });
|
|
4942
5494
|
break;
|
|
@@ -4945,6 +5497,7 @@ export class SshTui {
|
|
|
4945
5497
|
break;
|
|
4946
5498
|
}
|
|
4947
5499
|
this.lastActivity = Date.now();
|
|
5500
|
+
this.refreshOpenSubagentInspect(row);
|
|
4948
5501
|
this.markDirty();
|
|
4949
5502
|
};
|
|
4950
5503
|
handleSubagentStart = (info) => {
|
|
@@ -4956,31 +5509,53 @@ export class SshTui {
|
|
|
4956
5509
|
});
|
|
4957
5510
|
this.subagentSessions.add(sessionId);
|
|
4958
5511
|
this.lastActivity = Date.now();
|
|
5512
|
+
// The spawn description is a FIFO pair (task + parent call id). A card that
|
|
5513
|
+
// is already linked to a spawn keeps it: only a fresh child may take the
|
|
5514
|
+
// oldest waiting description, or two parallel spawns swap names.
|
|
4959
5515
|
const existing = this.findSubagentRow(sessionId);
|
|
5516
|
+
const pending = existing?.spawnCallId === undefined ? this.pendingSubagentTasks.shift() : undefined;
|
|
5517
|
+
const task = pending?.task;
|
|
5518
|
+
const modelProvider = this.subagentSelection.current.provider ?? this.currentProviderId();
|
|
5519
|
+
const startedText = t('sub.startedDetail', {
|
|
5520
|
+
provider: modelProvider,
|
|
5521
|
+
external: info.local ? '' : t('sub.external'),
|
|
5522
|
+
});
|
|
4960
5523
|
if (existing !== undefined) {
|
|
5524
|
+
existing.childSessionId = sessionId;
|
|
4961
5525
|
existing.runId = String(info.runId);
|
|
4962
5526
|
existing.provider = info.provider;
|
|
5527
|
+
existing.modelProvider = modelProvider;
|
|
4963
5528
|
existing.local = info.local;
|
|
4964
5529
|
existing.status = 'running';
|
|
4965
5530
|
existing.startedAt = Date.now();
|
|
4966
5531
|
existing.endedAt = undefined;
|
|
4967
5532
|
existing.stopReason = undefined;
|
|
5533
|
+
delete existing.failHint;
|
|
4968
5534
|
existing.lastActivity = t('sub.started');
|
|
4969
5535
|
existing.expanded = false;
|
|
4970
|
-
|
|
5536
|
+
if (pending !== undefined)
|
|
5537
|
+
existing.spawnCallId = pending.callId;
|
|
5538
|
+
if (task !== undefined)
|
|
5539
|
+
existing.task = task;
|
|
5540
|
+
appendSubagentLog(existing, { kind: 'system', text: startedText });
|
|
5541
|
+
this.refreshOpenSubagentInspect(existing);
|
|
4971
5542
|
}
|
|
4972
5543
|
else {
|
|
4973
5544
|
this.pushRow({
|
|
4974
5545
|
kind: 'subagent',
|
|
4975
5546
|
sessionId,
|
|
5547
|
+
childSessionId: sessionId,
|
|
5548
|
+
...(pending === undefined ? {} : { spawnCallId: pending.callId }),
|
|
4976
5549
|
runId: String(info.runId),
|
|
4977
5550
|
provider: info.provider,
|
|
5551
|
+
modelProvider,
|
|
4978
5552
|
local: info.local,
|
|
4979
5553
|
label: t('sub.label', { provider: info.provider }),
|
|
5554
|
+
...(task === undefined ? {} : { task }),
|
|
4980
5555
|
status: 'running',
|
|
4981
5556
|
startedAt: Date.now(),
|
|
4982
5557
|
lastActivity: t('sub.started'),
|
|
4983
|
-
logs: [{ kind: 'system', text:
|
|
5558
|
+
logs: [{ kind: 'system', text: startedText }],
|
|
4984
5559
|
expanded: false,
|
|
4985
5560
|
});
|
|
4986
5561
|
}
|
|
@@ -4992,37 +5567,79 @@ export class SshTui {
|
|
|
4992
5567
|
this.lastActivity = Date.now();
|
|
4993
5568
|
const output = info.lastAssistantMessage === undefined
|
|
4994
5569
|
? ''
|
|
4995
|
-
:
|
|
4996
|
-
const row = this.findSubagentRow(String(info.id))
|
|
5570
|
+
: clipSubagentActivity(collectText(info.lastAssistantMessage), 80);
|
|
5571
|
+
const row = this.findSubagentRow(String(info.id))
|
|
5572
|
+
?? this.rows.findLast((candidate) => candidate.kind === 'subagent' && candidate.runId === String(info.runId))
|
|
5573
|
+
// A chip rebuilt from the parent spawn tool has no child id yet: adopt the
|
|
5574
|
+
// oldest one so an end event settles it instead of adding a twin. A chip
|
|
5575
|
+
// replay already closed as `unknown` is adopted too, and corrected.
|
|
5576
|
+
?? this.rows.find((candidate) => candidate.kind === 'subagent'
|
|
5577
|
+
&& (candidate.status === 'running' || candidate.stopReason === 'unknown')
|
|
5578
|
+
&& candidate.childSessionId === undefined);
|
|
4997
5579
|
const failed = info.stopReason !== 'completed';
|
|
5580
|
+
const modelProvider = row?.modelProvider
|
|
5581
|
+
?? this.subagentSelection.current.provider
|
|
5582
|
+
?? this.currentProviderId();
|
|
5583
|
+
const explained = failed
|
|
5584
|
+
? describeSubagentFailure({
|
|
5585
|
+
stopReason: info.stopReason,
|
|
5586
|
+
message: output,
|
|
5587
|
+
provider: modelProvider,
|
|
5588
|
+
})
|
|
5589
|
+
: undefined;
|
|
5590
|
+
// A healthy end clears any hint an earlier failed attempt left on the row.
|
|
5591
|
+
const hint = explained?.hint;
|
|
5592
|
+
const endText = hint === undefined
|
|
5593
|
+
? t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`)
|
|
5594
|
+
: hint;
|
|
4998
5595
|
if (row !== undefined) {
|
|
4999
5596
|
row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
|
|
5000
5597
|
row.endedAt = Date.now();
|
|
5001
5598
|
row.stopReason = info.stopReason;
|
|
5599
|
+
row.childSessionId = String(info.id);
|
|
5600
|
+
row.modelProvider = modelProvider;
|
|
5601
|
+
if (hint === undefined)
|
|
5602
|
+
delete row.failHint;
|
|
5603
|
+
else
|
|
5604
|
+
row.failHint = hint;
|
|
5002
5605
|
appendSubagentLog(row, {
|
|
5003
5606
|
kind: failed ? 'result' : 'assistant',
|
|
5004
|
-
text:
|
|
5607
|
+
text: endText,
|
|
5005
5608
|
});
|
|
5609
|
+
this.refreshOpenSubagentInspect(row);
|
|
5006
5610
|
}
|
|
5007
5611
|
else {
|
|
5008
5612
|
this.pushRow({
|
|
5009
5613
|
kind: 'subagent',
|
|
5010
5614
|
sessionId: String(info.id),
|
|
5615
|
+
childSessionId: String(info.id),
|
|
5011
5616
|
runId: String(info.runId),
|
|
5012
5617
|
provider: info.provider,
|
|
5618
|
+
modelProvider,
|
|
5013
5619
|
local: info.local,
|
|
5014
5620
|
label: t('sub.label', { provider: info.provider }),
|
|
5015
5621
|
status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
|
|
5016
5622
|
startedAt: Date.now(),
|
|
5017
5623
|
endedAt: Date.now(),
|
|
5018
5624
|
stopReason: info.stopReason,
|
|
5019
|
-
|
|
5020
|
-
|
|
5625
|
+
...(explained === undefined ? {} : { failHint: explained.hint }),
|
|
5626
|
+
lastActivity: endText,
|
|
5627
|
+
logs: [{ kind: failed ? 'result' : 'system', text: endText }],
|
|
5021
5628
|
expanded: false,
|
|
5022
5629
|
});
|
|
5023
5630
|
}
|
|
5024
5631
|
this.markDirty();
|
|
5025
5632
|
};
|
|
5633
|
+
/** Keep an open inspect overlay in sync with the live child log. */
|
|
5634
|
+
refreshOpenSubagentInspect(row) {
|
|
5635
|
+
const dialog = this.dialog;
|
|
5636
|
+
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
5637
|
+
return;
|
|
5638
|
+
if (dialog.subagentSessionId !== this.subagentInspectId(row))
|
|
5639
|
+
return;
|
|
5640
|
+
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
5641
|
+
dialog.lines = subagentInspectLines(row);
|
|
5642
|
+
}
|
|
5026
5643
|
// ── approval and questions ──────────────────────────────────────────────
|
|
5027
5644
|
hasLiveDisplay() {
|
|
5028
5645
|
if (this.disposed || this.exiting)
|
|
@@ -5112,7 +5729,7 @@ export class SshTui {
|
|
|
5112
5729
|
const selection = this.subagentSelection.current;
|
|
5113
5730
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5114
5731
|
const provider = selection.provider ?? parentProvider;
|
|
5115
|
-
const model = subagentModelMatchesProvider(provider, selection.model)
|
|
5732
|
+
const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
|
|
5116
5733
|
? selection.model
|
|
5117
5734
|
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
5118
5735
|
return { provider, model };
|
|
@@ -5338,9 +5955,7 @@ export class SshTui {
|
|
|
5338
5955
|
return 'cancelled';
|
|
5339
5956
|
}
|
|
5340
5957
|
}
|
|
5341
|
-
const agentLabel =
|
|
5342
|
-
? t('approval.thisSession')
|
|
5343
|
-
: t('sub.agentLabel', { id: request.agent.id });
|
|
5958
|
+
const agentLabel = t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5344
5959
|
return new Promise((resolve) => {
|
|
5345
5960
|
if (request.signal?.aborted === true) {
|
|
5346
5961
|
resolve('cancelled');
|
|
@@ -5378,7 +5993,7 @@ export class SshTui {
|
|
|
5378
5993
|
const answers = [];
|
|
5379
5994
|
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
5380
5995
|
? undefined
|
|
5381
|
-
: t('sub.agentLabel', {
|
|
5996
|
+
: t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5382
5997
|
const cards = [];
|
|
5383
5998
|
for (const question of request.questions) {
|
|
5384
5999
|
const card = {
|
|
@@ -5455,6 +6070,7 @@ export class SshTui {
|
|
|
5455
6070
|
openDialog(dialog) {
|
|
5456
6071
|
if (this.dialog === undefined) {
|
|
5457
6072
|
this.dialog = dialog;
|
|
6073
|
+
this.echoLineModeDialog(dialog);
|
|
5458
6074
|
}
|
|
5459
6075
|
else {
|
|
5460
6076
|
this.dialogQueue.push(dialog);
|
|
@@ -5467,9 +6083,53 @@ export class SshTui {
|
|
|
5467
6083
|
const next = this.dialogQueue.shift();
|
|
5468
6084
|
if (next !== undefined) {
|
|
5469
6085
|
this.dialog = next;
|
|
6086
|
+
this.echoLineModeDialog(next);
|
|
5470
6087
|
this.markDirty();
|
|
5471
6088
|
}
|
|
5472
6089
|
}
|
|
6090
|
+
/**
|
|
6091
|
+
* Line mode has no framed dialog. Write the prompt (and its keys) into the
|
|
6092
|
+
* log once when it becomes active, so a `tee` or a screen reader can answer
|
|
6093
|
+
* it. Options stay on the framed path; the log only needs the question.
|
|
6094
|
+
*/
|
|
6095
|
+
echoLineModeDialog(dialog) {
|
|
6096
|
+
if (!this.lineMode)
|
|
6097
|
+
return;
|
|
6098
|
+
if (dialog.kind === 'confirm') {
|
|
6099
|
+
this.pushRow({ kind: 'system', text: dialog.prompt });
|
|
6100
|
+
this.pushRow({ kind: 'system', text: dialog.hint });
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
if (dialog.kind === 'questions') {
|
|
6104
|
+
const header = t('dialog.ask', {
|
|
6105
|
+
index: dialog.index + 1,
|
|
6106
|
+
total: dialog.total,
|
|
6107
|
+
question: dialog.question.question,
|
|
6108
|
+
});
|
|
6109
|
+
this.pushRow({ kind: 'system', text: header });
|
|
6110
|
+
if (dialog.question.header !== undefined && dialog.question.header !== '') {
|
|
6111
|
+
this.pushRow({ kind: 'system', text: dialog.question.header });
|
|
6112
|
+
}
|
|
6113
|
+
const options = dialog.question.options ?? [];
|
|
6114
|
+
for (const [index, option] of options.entries()) {
|
|
6115
|
+
if (option === undefined)
|
|
6116
|
+
continue;
|
|
6117
|
+
const key = QUESTION_OPTION_KEYS[index] ?? String(index + 1);
|
|
6118
|
+
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
6119
|
+
this.pushRow({ kind: 'system', text: ` ${key} ${option.label}${extra}` });
|
|
6120
|
+
}
|
|
6121
|
+
this.pushRow({
|
|
6122
|
+
kind: 'system',
|
|
6123
|
+
text: options.length === 0
|
|
6124
|
+
? t('dialog.freeform')
|
|
6125
|
+
: dialog.question.multiSelect === true ? t('dialog.multiHint') : t('dialog.singleHint'),
|
|
6126
|
+
});
|
|
6127
|
+
return;
|
|
6128
|
+
}
|
|
6129
|
+
if (dialog.kind === 'onboarding') {
|
|
6130
|
+
this.pushRow({ kind: 'system', text: t('onboard.title') });
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
5473
6133
|
removeQueuedDialog(dialog) {
|
|
5474
6134
|
const index = this.dialogQueue.indexOf(dialog);
|
|
5475
6135
|
if (index !== -1)
|
|
@@ -5552,43 +6212,281 @@ export class SshTui {
|
|
|
5552
6212
|
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5553
6213
|
return section?.providers?.[provider];
|
|
5554
6214
|
}
|
|
5555
|
-
/** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
|
|
5556
|
-
openCodeListingBaseURL(provider) {
|
|
5557
|
-
if (provider === 'opencode-go')
|
|
5558
|
-
return providerTemplates()['opencode-go'].defaultBaseUrl;
|
|
5559
|
-
if (provider === 'opencode')
|
|
5560
|
-
return OPENCODE_ZEN_BASE_URL;
|
|
5561
|
-
return undefined;
|
|
5562
|
-
}
|
|
5563
6215
|
/**
|
|
5564
|
-
*
|
|
5565
|
-
*
|
|
5566
|
-
*
|
|
5567
|
-
*
|
|
6216
|
+
* Every settings-backed row one gateway family occupies, base first.
|
|
6217
|
+
*
|
|
6218
|
+
* `llm-pi-ai` puts the wire protocol on the provider entry, so a gateway
|
|
6219
|
+
* whose catalogue spans protocols (Command Code, OpenCode Go) is stored as
|
|
6220
|
+
* sibling rows. To the user they are one supplier: the TUI lists, picks and
|
|
6221
|
+
* remembers them under the family's base id, and files each picked model on
|
|
6222
|
+
* the row that actually serves it.
|
|
6223
|
+
*
|
|
6224
|
+
* The `-responses` / `-completions` / `-messages` suffix is only a naming
|
|
6225
|
+
* convention, so a row is adopted into the family only when it corroborates:
|
|
6226
|
+
* it must speak the protocol its suffix names, point at the same endpoint,
|
|
6227
|
+
* and (when both name one) carry the same display name. A provider someone
|
|
6228
|
+
* legitimately called `foo-messages` on another host therefore stays its own
|
|
6229
|
+
* supplier instead of vanishing from `/provider` behind `foo`. A family of
|
|
6230
|
+
* one — the ordinary case, and a sibling whose base row is missing — stays
|
|
6231
|
+
* exactly as configured.
|
|
5568
6232
|
*/
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
const profile = this.piAiProviderProfile(provider);
|
|
5572
|
-
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5573
|
-
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
5574
|
-
? profile.baseURL.trim()
|
|
5575
|
-
: this.openCodeListingBaseURL(provider);
|
|
5576
|
-
if (baseURL === undefined)
|
|
6233
|
+
providerFamilyRows(provider) {
|
|
6234
|
+
if (provider === '')
|
|
5577
6235
|
return [];
|
|
5578
|
-
const
|
|
5579
|
-
const
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
6236
|
+
const base = baseProviderIdOf(provider);
|
|
6237
|
+
const baseRow = this.piAiProviderProfile(base);
|
|
6238
|
+
const rows = [base];
|
|
6239
|
+
for (const protocol of GATEWAY_PROTOCOL_PREFERENCE) {
|
|
6240
|
+
const sibling = siblingProviderId(base, protocol);
|
|
6241
|
+
if (sibling === provider || rows.includes(sibling)) {
|
|
6242
|
+
if (this.isFamilyRow(baseRow, sibling, protocol))
|
|
6243
|
+
rows.push(sibling);
|
|
6244
|
+
continue;
|
|
6245
|
+
}
|
|
6246
|
+
if (this.isFamilyRow(baseRow, sibling, protocol))
|
|
6247
|
+
rows.push(sibling);
|
|
6248
|
+
}
|
|
6249
|
+
const present = rows.filter(row => this.piAiProviderProfile(row) !== undefined);
|
|
6250
|
+
if (present.length <= 1) {
|
|
6251
|
+
// A lone sibling is not a family — it is just this provider.
|
|
6252
|
+
return baseRow !== undefined || present.includes(provider) ? present : [provider];
|
|
6253
|
+
}
|
|
6254
|
+
return present;
|
|
6255
|
+
}
|
|
6256
|
+
/**
|
|
6257
|
+
* Whether `sibling` is genuinely the same gateway as `base`, on another
|
|
6258
|
+
* protocol: its `api` matches its suffix, its endpoint matches the base row's,
|
|
6259
|
+
* and the two labels agree when both are set.
|
|
6260
|
+
*/
|
|
6261
|
+
isFamilyRow(baseRow, sibling, protocol) {
|
|
6262
|
+
if (baseRow === undefined)
|
|
6263
|
+
return false;
|
|
6264
|
+
const profile = this.piAiProviderProfile(sibling);
|
|
6265
|
+
if (profile === undefined)
|
|
6266
|
+
return false;
|
|
6267
|
+
const endpoint = (value) => typeof value === 'string' ? value.replace(/\/+$/u, '').toLowerCase() : '';
|
|
6268
|
+
const api = declaredProtocol(profile.api);
|
|
6269
|
+
if (api !== undefined && api !== protocol)
|
|
6270
|
+
return false;
|
|
6271
|
+
const baseUrl = endpoint(baseRow.baseURL);
|
|
6272
|
+
const siblingUrl = endpoint(profile.baseURL);
|
|
6273
|
+
if (baseUrl !== '' && siblingUrl !== '' && baseUrl !== siblingUrl)
|
|
6274
|
+
return false;
|
|
6275
|
+
const baseLabel = typeof baseRow.displayName === 'string' ? baseRow.displayName.trim() : '';
|
|
6276
|
+
const siblingLabel = typeof profile.displayName === 'string' ? profile.displayName.trim() : '';
|
|
6277
|
+
if (baseLabel !== '' && siblingLabel !== '' && baseLabel !== siblingLabel)
|
|
6278
|
+
return false;
|
|
6279
|
+
return true;
|
|
6280
|
+
}
|
|
6281
|
+
/** The id the TUI shows for a route: its family's base when it is spread. */
|
|
6282
|
+
displayProviderId(provider) {
|
|
6283
|
+
const rows = this.providerFamilyRows(provider);
|
|
6284
|
+
return rows.length > 1 ? (rows[0] ?? provider) : provider;
|
|
6285
|
+
}
|
|
6286
|
+
/** The models one row configures, as ids with the labels settings carry. */
|
|
6287
|
+
configuredModelsOf(row) {
|
|
6288
|
+
const models = this.piAiProviderProfile(row)?.models;
|
|
6289
|
+
if (!Array.isArray(models))
|
|
5585
6290
|
return [];
|
|
5586
|
-
const
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
6291
|
+
const out = [];
|
|
6292
|
+
for (const raw of models) {
|
|
6293
|
+
if (typeof raw === 'string') {
|
|
6294
|
+
if (raw !== '')
|
|
6295
|
+
out.push({ id: raw });
|
|
6296
|
+
continue;
|
|
6297
|
+
}
|
|
6298
|
+
if (raw === null || typeof raw !== 'object')
|
|
6299
|
+
continue;
|
|
6300
|
+
const entry = raw;
|
|
6301
|
+
if (typeof entry.id !== 'string' || entry.id === '')
|
|
6302
|
+
continue;
|
|
6303
|
+
out.push({
|
|
6304
|
+
id: entry.id,
|
|
6305
|
+
...(typeof entry.name === 'string' && entry.name !== '' ? { label: entry.name } : {}),
|
|
6306
|
+
});
|
|
6307
|
+
}
|
|
6308
|
+
return out;
|
|
6309
|
+
}
|
|
6310
|
+
/**
|
|
6311
|
+
* The protocols the gateway publishes for one model, best-effort.
|
|
6312
|
+
*
|
|
6313
|
+
* Used when a model is being filed but no picker listed it yet (`/model <id>`
|
|
6314
|
+
* on a new id, a `--model` override, a `/submodel` pin): the gateway's own
|
|
6315
|
+
* answer is the only thing that can put it on a route that can send it.
|
|
6316
|
+
* Any failure is "no opinion", never a blocked selection.
|
|
6317
|
+
*/
|
|
6318
|
+
async advertisedFor(family, model) {
|
|
6319
|
+
const rows = this.providerFamilyRows(family);
|
|
6320
|
+
if (rows.length <= 1)
|
|
6321
|
+
return [];
|
|
6322
|
+
// A model the last listing already filed needs no second opinion, and that
|
|
6323
|
+
// is the common case: `/model` re-picking a listed model must never wait on
|
|
6324
|
+
// the gateway again. Only an unlisted id (a hand-typed one, a `--model`
|
|
6325
|
+
// override) asks, and even then the wait is bounded — an unreachable gateway
|
|
6326
|
+
// must not stall a selection for the endpoint's full timeout.
|
|
6327
|
+
if (this.familyModelOwner.has(`${family}\u0000${model}`))
|
|
6328
|
+
return [];
|
|
6329
|
+
if (this.configuredModelsOf(rows[0] ?? family).some(entry => entry.id === model))
|
|
6330
|
+
return [];
|
|
6331
|
+
try {
|
|
6332
|
+
const listing = await this.discoverGatewayListing(rows[0] ?? family, AbortSignal.timeout(5_000));
|
|
6333
|
+
return advertisedProtocols(listing.endpoints, model);
|
|
6334
|
+
}
|
|
6335
|
+
catch {
|
|
6336
|
+
return [];
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
/**
|
|
6340
|
+
* The row of a gateway family that serves `model`: the one learned from the
|
|
6341
|
+
* last listing, else the one that already configures it, else the base —
|
|
6342
|
+
* where a brand-new model lands and is re-filed from.
|
|
6343
|
+
*/
|
|
6344
|
+
/**
|
|
6345
|
+
* Record which row a family's model belongs to, keeping the map bounded.
|
|
6346
|
+
*
|
|
6347
|
+
* Re-inserting keeps a key fresh, and the oldest half is dropped once the cap
|
|
6348
|
+
* is crossed, so a gateway whose catalogue is re-listed on every `/model`
|
|
6349
|
+
* does not accumulate a second copy of itself each time.
|
|
6350
|
+
*/
|
|
6351
|
+
rememberFamilyOwner(base, model, row) {
|
|
6352
|
+
const key = `${base}\u0000${model}`;
|
|
6353
|
+
this.familyModelOwner.delete(key);
|
|
6354
|
+
this.familyModelOwner.set(key, row);
|
|
6355
|
+
if (this.familyModelOwner.size <= FAMILY_OWNER_CACHE_MAX)
|
|
6356
|
+
return;
|
|
6357
|
+
let remaining = Math.floor(FAMILY_OWNER_CACHE_MAX / 2);
|
|
6358
|
+
for (const oldest of this.familyModelOwner.keys()) {
|
|
6359
|
+
if (remaining <= 0)
|
|
6360
|
+
break;
|
|
6361
|
+
this.familyModelOwner.delete(oldest);
|
|
6362
|
+
remaining -= 1;
|
|
6363
|
+
}
|
|
6364
|
+
}
|
|
6365
|
+
familyOwnerRow(base, model, advertised) {
|
|
6366
|
+
const rows = this.providerFamilyRows(base);
|
|
6367
|
+
if (rows.length <= 1)
|
|
6368
|
+
return base;
|
|
6369
|
+
// What the gateway itself says comes first: a model no row configures and
|
|
6370
|
+
// no picker listed (`/model <id>` on a new id, a `--model` override) still
|
|
6371
|
+
// has a protocol, and filing it anywhere else makes the guard below refuse
|
|
6372
|
+
// a model the gateway is happy to serve.
|
|
6373
|
+
for (const protocol of advertised ?? []) {
|
|
6374
|
+
const row = rows.find(candidate => declaredProtocol(this.piAiProviderProfile(candidate)?.api) === protocol);
|
|
6375
|
+
if (row !== undefined)
|
|
6376
|
+
return row;
|
|
6377
|
+
}
|
|
6378
|
+
// The row that actually served the model in the last listing is evidence,
|
|
6379
|
+
// not a hint: it is the row the gateway said could send it. A row that
|
|
6380
|
+
// merely has the model configured used to win here, which sent an explicit
|
|
6381
|
+
// `/model <id>` back to the protocol the listing had just ruled out (the
|
|
6382
|
+
// completions row is still listed first for a stale responses entry).
|
|
6383
|
+
const learned = this.familyModelOwner.get(`${base}\u0000${model}`);
|
|
6384
|
+
if (learned !== undefined && rows.includes(learned))
|
|
6385
|
+
return learned;
|
|
6386
|
+
// No listing this run (a direct `/model <id>`, or an unreachable gateway):
|
|
6387
|
+
// keep the placement that is already configured. A model on two rows takes
|
|
6388
|
+
// the first, which is the family's own preference order — the same order
|
|
6389
|
+
// the wizard would file it under.
|
|
6390
|
+
for (const row of rows) {
|
|
6391
|
+
if (this.configuredModelsOf(row).some(entry => entry.id === model))
|
|
6392
|
+
return row;
|
|
6393
|
+
}
|
|
6394
|
+
return rows[0] ?? base;
|
|
6395
|
+
}
|
|
6396
|
+
/** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
|
|
6397
|
+
openCodeListingBaseURL(provider) {
|
|
6398
|
+
if (provider === 'opencode-go')
|
|
6399
|
+
return providerTemplates()['opencode-go'].defaultBaseUrl;
|
|
6400
|
+
if (provider === 'opencode')
|
|
6401
|
+
return OPENCODE_ZEN_BASE_URL;
|
|
6402
|
+
return undefined;
|
|
6403
|
+
}
|
|
6404
|
+
/**
|
|
6405
|
+
* Fetch the live model list for an OpenCode or third-party provider from its
|
|
6406
|
+
* OpenAI-compatible listing endpoint. The provider route is deliberately not
|
|
6407
|
+
* passed to discovery: pi-ai would answer a catalog route from its installed
|
|
6408
|
+
* registry, while the TUI wants the endpoint's current list.
|
|
6409
|
+
*
|
|
6410
|
+
* A row speaks exactly one protocol (`api` on the provider entry), so with a
|
|
6411
|
+
* gateway that publishes `supported_endpoints` the list is narrowed to the
|
|
6412
|
+
* models that row can actually serve — otherwise `/model` on
|
|
6413
|
+
* `command-code-messages` would offer, and then persist, a model that only
|
|
6414
|
+
* answers `/responses`.
|
|
6415
|
+
*/
|
|
6416
|
+
async discoverEndpointModels(provider) {
|
|
6417
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
|
|
6418
|
+
// Interactive callers get a shorter budget than the wizard's: `/model`
|
|
6419
|
+
// falling back to the configured list after a few seconds reads as "this
|
|
6420
|
+
// gateway is unreachable", while 15s reads as "the TUI hung".
|
|
6421
|
+
const { models, endpoints } = await this.discoverGatewayListing(provider, AbortSignal.timeout(6_000));
|
|
6422
|
+
return models.filter(model => gatewayServesModel(endpoints, model.id, protocol));
|
|
6423
|
+
}
|
|
6424
|
+
/**
|
|
6425
|
+
* One gateway's own model listing: every model the endpoint returns plus the
|
|
6426
|
+
* route table it publishes. The caller narrows it to a protocol; a gateway
|
|
6427
|
+
* spread over rows asks once and files the answer per row, so opening a
|
|
6428
|
+
* picker costs one listing instead of one per sibling.
|
|
6429
|
+
*/
|
|
6430
|
+
async discoverGatewayListing(provider, signal) {
|
|
6431
|
+
const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
6432
|
+
const profile = this.piAiProviderProfile(provider);
|
|
6433
|
+
const source = openCodeSourceFor(provider, llmPiAi);
|
|
6434
|
+
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
6435
|
+
? profile.baseURL.trim()
|
|
6436
|
+
: this.openCodeListingBaseURL(provider);
|
|
6437
|
+
if (baseURL === undefined)
|
|
6438
|
+
return { models: [], endpoints: undefined };
|
|
6439
|
+
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
6440
|
+
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
6441
|
+
? profile.apiKeyEnv.trim()
|
|
6442
|
+
: source?.apiKeyEnv;
|
|
6443
|
+
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
6444
|
+
const llm = this.ctx.get('llm');
|
|
6445
|
+
if (llm === undefined)
|
|
6446
|
+
return { models: [], endpoints: undefined };
|
|
6447
|
+
const [discovered, endpoints] = await Promise.all([
|
|
6448
|
+
discoverProviderModels(llm, {
|
|
6449
|
+
baseURL,
|
|
6450
|
+
...(api === undefined ? {} : { api }),
|
|
6451
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
6452
|
+
}, signal ?? AbortSignal.timeout(15_000)).catch(() => []),
|
|
6453
|
+
apiKey === undefined ? Promise.resolve(undefined) : fetchModelEndpoints(baseURL, apiKey, signal),
|
|
6454
|
+
]);
|
|
6455
|
+
// The route table is the point of asking a gateway that publishes one: it is
|
|
6456
|
+
// what files each model on the protocol that can actually send it, and the
|
|
6457
|
+
// discovered list alone cannot. A gateway whose listing answered from the
|
|
6458
|
+
// installed catalog still gets its endpoints read here, and a gateway that
|
|
6459
|
+
// publishes no table keeps the discovered list as-is.
|
|
6460
|
+
const named = new Map(discovered.map(model => [model.id, model.name || model.id]));
|
|
6461
|
+
const models = endpoints === undefined
|
|
6462
|
+
? discovered.map(model => ({ id: model.id, label: model.name || model.id }))
|
|
6463
|
+
: [...endpoints.keys()].map(id => ({ id, label: named.get(id) ?? id }));
|
|
6464
|
+
return { models, endpoints };
|
|
6465
|
+
}
|
|
6466
|
+
/**
|
|
6467
|
+
* The row of the same gateway that the gateway itself says serves `model`,
|
|
6468
|
+
* when that is not the row the caller is on. `undefined` when the gateway has
|
|
6469
|
+
* no opinion, when the current row already serves it, or when the model would
|
|
6470
|
+
* need a row that does not exist yet.
|
|
6471
|
+
*/
|
|
6472
|
+
rowServingModel(provider, modelId, endpoints) {
|
|
6473
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
|
|
6474
|
+
if (gatewayServesModel(endpoints, modelId, protocol))
|
|
6475
|
+
return undefined;
|
|
6476
|
+
const wanted = advertisedProtocols(endpoints, modelId)[0];
|
|
6477
|
+
if (wanted === undefined)
|
|
6478
|
+
return undefined;
|
|
6479
|
+
// The base id is a row of its own: use it when it already speaks `wanted`,
|
|
6480
|
+
// otherwise the sibling that does.
|
|
6481
|
+
const base = baseProviderIdOf(provider);
|
|
6482
|
+
const rows = declaredProtocol(this.piAiProviderProfile(base)?.api) === wanted
|
|
6483
|
+
? [base, siblingProviderId(base, wanted)]
|
|
6484
|
+
: [siblingProviderId(base, wanted)];
|
|
6485
|
+
for (const row of rows) {
|
|
6486
|
+
if (row !== provider && this.piAiProviderProfile(row) !== undefined)
|
|
6487
|
+
return row;
|
|
6488
|
+
}
|
|
6489
|
+
return undefined;
|
|
5592
6490
|
}
|
|
5593
6491
|
/** Add one endpoint-listed model to the stored provider profile when needed. */
|
|
5594
6492
|
async ensureProviderModelConfigured(provider, modelId) {
|
|
@@ -5612,6 +6510,39 @@ export class SshTui {
|
|
|
5612
6510
|
}
|
|
5613
6511
|
if (ids.has(modelId))
|
|
5614
6512
|
return true;
|
|
6513
|
+
// Reached by a direct `/model <id>` too, where the picker's own filter never
|
|
6514
|
+
// ran: refuse to store a model this row's protocol cannot serve, and say
|
|
6515
|
+
// which sibling row can.
|
|
6516
|
+
const protocol = declaredProtocol(profile.api);
|
|
6517
|
+
const baseURL = typeof profile.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
6518
|
+
? profile.baseURL.trim()
|
|
6519
|
+
: this.openCodeListingBaseURL(provider);
|
|
6520
|
+
if (protocol !== undefined && baseURL !== undefined) {
|
|
6521
|
+
const apiKeyEnv = typeof profile.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== '' ? profile.apiKeyEnv.trim() : undefined;
|
|
6522
|
+
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
6523
|
+
const endpoints = apiKey === undefined ? undefined : await fetchModelEndpoints(baseURL, apiKey);
|
|
6524
|
+
if (!gatewayServesModel(endpoints, modelId, protocol)) {
|
|
6525
|
+
const target = this.rowServingModel(provider, modelId, endpoints);
|
|
6526
|
+
// Report both names: the family is what `/provider` offers, the row is
|
|
6527
|
+
// what the settings file calls it. Sending the user to a hidden row was
|
|
6528
|
+
// a dead end — `/provider` lists families, so the hint names the family
|
|
6529
|
+
// and `/model` (which files the pick on the right row itself).
|
|
6530
|
+
const family = this.displayProviderId(provider);
|
|
6531
|
+
this.pushRow({
|
|
6532
|
+
kind: 'error',
|
|
6533
|
+
text: t('model.wrongRoute', {
|
|
6534
|
+
model: modelId,
|
|
6535
|
+
provider: family === provider ? provider : `${family}(${provider})`,
|
|
6536
|
+
endpoint: GATEWAY_PROTOCOL_ENDPOINT[protocol],
|
|
6537
|
+
hint: target === undefined
|
|
6538
|
+
? t('model.wrongRouteSetup')
|
|
6539
|
+
: t('model.wrongRouteTarget', { provider: family }),
|
|
6540
|
+
}),
|
|
6541
|
+
});
|
|
6542
|
+
this.markDirty();
|
|
6543
|
+
return false;
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
5615
6546
|
const modelEntry = { id: modelId };
|
|
5616
6547
|
// A model added here skips the wizard, so size it from the installed
|
|
5617
6548
|
// catalog the same way `/setup` does; the route default covers a miss.
|
|
@@ -5621,14 +6552,18 @@ export class SshTui {
|
|
|
5621
6552
|
if (contextWindow !== undefined)
|
|
5622
6553
|
modelEntry.contextWindow = contextWindow;
|
|
5623
6554
|
}
|
|
5624
|
-
|
|
6555
|
+
// Same rule `/setup` follows: a route whose dialect the installed catalog
|
|
6556
|
+
// does not describe must still declare its offered levels, or the Harness
|
|
6557
|
+
// reads the model as non-reasoning and refuses every effort the TUI offers.
|
|
6558
|
+
const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning)
|
|
6559
|
+
?? (declaresOfferedReasoning(typeof profile.api === 'string' ? profile.api : '') ? handDeclaredReasoningEfforts() : undefined);
|
|
5625
6560
|
if (reasoningEfforts !== undefined)
|
|
5626
6561
|
modelEntry.reasoningEfforts = reasoningEfforts;
|
|
5627
6562
|
try {
|
|
5628
6563
|
await settings.mutate(settingsNamespace('llm-pi-ai'), [
|
|
5629
6564
|
{ op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
|
|
5630
6565
|
]);
|
|
5631
|
-
this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider }) });
|
|
6566
|
+
this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider: this.displayProviderId(provider) }) });
|
|
5632
6567
|
this.markDirty();
|
|
5633
6568
|
return true;
|
|
5634
6569
|
}
|
|
@@ -5673,16 +6608,22 @@ export class SshTui {
|
|
|
5673
6608
|
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
5674
6609
|
listSelectableProviders() {
|
|
5675
6610
|
const llm = this.ctx.get('llm');
|
|
5676
|
-
const current = this.currentProviderId();
|
|
6611
|
+
const current = this.displayProviderId(this.currentProviderId());
|
|
5677
6612
|
const seen = new Set();
|
|
5678
6613
|
const out = [];
|
|
5679
6614
|
const add = (id, name) => {
|
|
5680
|
-
if (id === ''
|
|
6615
|
+
if (id === '')
|
|
5681
6616
|
return;
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
6617
|
+
// A gateway spread over protocol rows is one choice here: the rows are an
|
|
6618
|
+
// implementation detail of `llm-pi-ai` (one wire protocol per entry), not
|
|
6619
|
+
// something the user should have to pick between.
|
|
6620
|
+
const row = this.displayProviderId(id);
|
|
6621
|
+
if (row === '' || seen.has(row))
|
|
6622
|
+
return;
|
|
6623
|
+
seen.add(row);
|
|
6624
|
+
const kind = describeProviderRoute(row);
|
|
6625
|
+
const display = name !== undefined && name !== '' && name !== row ? name : kind.short;
|
|
6626
|
+
out.push({ id: row, label: `${display} · ${row}` });
|
|
5686
6627
|
};
|
|
5687
6628
|
add(current);
|
|
5688
6629
|
for (const info of llm?.listProviders() ?? [])
|
|
@@ -5700,6 +6641,9 @@ export class SshTui {
|
|
|
5700
6641
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
5701
6642
|
];
|
|
5702
6643
|
async loadModelOptions(provider) {
|
|
6644
|
+
const family = this.providerFamilyRows(provider);
|
|
6645
|
+
if (family.length > 1)
|
|
6646
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
5703
6647
|
const llm = this.ctx.get('llm');
|
|
5704
6648
|
let options = [];
|
|
5705
6649
|
let source = t('model.configured');
|
|
@@ -5754,16 +6698,86 @@ export class SshTui {
|
|
|
5754
6698
|
}
|
|
5755
6699
|
return { options, source };
|
|
5756
6700
|
}
|
|
6701
|
+
/**
|
|
6702
|
+
* The model list of a gateway spread over protocol rows.
|
|
6703
|
+
*
|
|
6704
|
+
* Every row is asked what it serves and the answers are merged under the
|
|
6705
|
+
* family's base id, so the user picks from one list. Each model remembers the
|
|
6706
|
+
* row that offered it, which is what routes the pick back to the protocol
|
|
6707
|
+
* that can actually send it — the split stays invisible end to end.
|
|
6708
|
+
*/
|
|
6709
|
+
async loadFamilyModelOptions(base, family) {
|
|
6710
|
+
const llm = this.ctx.get('llm');
|
|
6711
|
+
const options = [];
|
|
6712
|
+
const seen = new Set();
|
|
6713
|
+
const add = (row, id, label) => {
|
|
6714
|
+
if (id === '' || seen.has(id))
|
|
6715
|
+
return;
|
|
6716
|
+
seen.add(id);
|
|
6717
|
+
this.rememberFamilyOwner(base, id, row);
|
|
6718
|
+
options.push({ id, label: label !== undefined && label !== '' ? label : id });
|
|
6719
|
+
};
|
|
6720
|
+
const previousStatus = this.status;
|
|
6721
|
+
this.status = t('model.fetching', { provider: base });
|
|
6722
|
+
this.markDirty();
|
|
6723
|
+
try {
|
|
6724
|
+
// The gateway's own listing is what decides which row serves what, so it
|
|
6725
|
+
// is answered first and `add` keeps the first row that claims an id. Doing
|
|
6726
|
+
// the configured rows first would let a leftover entry on the base row
|
|
6727
|
+
// shadow the sibling the gateway actually files the model under — the
|
|
6728
|
+
// picker would then show a model under a protocol that cannot send it,
|
|
6729
|
+
// and `/model` would write it back onto that row.
|
|
6730
|
+
const listings = await this.discoverGatewayListing(base, AbortSignal.timeout(6_000)).catch(() => undefined);
|
|
6731
|
+
if (listings !== undefined) {
|
|
6732
|
+
for (const row of family) {
|
|
6733
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(row)?.api);
|
|
6734
|
+
for (const model of listings.models) {
|
|
6735
|
+
if (gatewayServesModel(listings.endpoints, model.id, protocol))
|
|
6736
|
+
add(row, model.id, model.label);
|
|
6737
|
+
}
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
// Whatever the endpoint did not answer: the rows that already configure a
|
|
6741
|
+
// model keep it (so a gateway that is down still offers its own list).
|
|
6742
|
+
for (const row of family) {
|
|
6743
|
+
for (const model of this.configuredModelsOf(row))
|
|
6744
|
+
add(row, model.id, model.label);
|
|
6745
|
+
}
|
|
6746
|
+
// Models the installed catalog knows but neither source listed.
|
|
6747
|
+
for (const row of family) {
|
|
6748
|
+
try {
|
|
6749
|
+
for (const model of (await llm?.listModels(row)) ?? [])
|
|
6750
|
+
add(row, model.id, model.name || model.id);
|
|
6751
|
+
}
|
|
6752
|
+
catch {
|
|
6753
|
+
// The endpoint listing stands alone when the catalog cannot be read.
|
|
6754
|
+
}
|
|
6755
|
+
}
|
|
6756
|
+
}
|
|
6757
|
+
finally {
|
|
6758
|
+
this.status = previousStatus;
|
|
6759
|
+
this.markDirty();
|
|
6760
|
+
}
|
|
6761
|
+
if (options.length === 0) {
|
|
6762
|
+
const remembered = this.rememberedRoute(base)?.model;
|
|
6763
|
+
if (remembered !== undefined)
|
|
6764
|
+
options.push({ id: remembered, label: remembered });
|
|
6765
|
+
}
|
|
6766
|
+
return { options, source: options.length > 0 ? t('model.live') : t('model.configured') };
|
|
6767
|
+
}
|
|
5757
6768
|
/** /model: models and effort for the current provider only. */
|
|
5758
6769
|
async runModelCommand() {
|
|
5759
6770
|
const provider = this.currentProviderId();
|
|
6771
|
+
// The list is the whole family's; the question names the gateway, not the
|
|
6772
|
+
// protocol row the current request happens to use.
|
|
6773
|
+
const display = this.displayProviderId(provider);
|
|
5760
6774
|
const current = this.selectionRef?.current;
|
|
5761
6775
|
const loaded = await this.loadModelOptions(provider);
|
|
5762
6776
|
let modelOptions = loaded.options;
|
|
5763
6777
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
5764
6778
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
5765
6779
|
}
|
|
5766
|
-
const selected = await this.pickModelOption(modelOptions,
|
|
6780
|
+
const selected = await this.pickModelOption(modelOptions, display, loaded.source, current?.model);
|
|
5767
6781
|
if (selected === undefined)
|
|
5768
6782
|
return;
|
|
5769
6783
|
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
@@ -5771,7 +6785,10 @@ export class SshTui {
|
|
|
5771
6785
|
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
5772
6786
|
async runProviderCommand() {
|
|
5773
6787
|
const providers = this.listSelectableProviders();
|
|
5774
|
-
|
|
6788
|
+
// The list holds family ids, so the highlight must be asked for in the same
|
|
6789
|
+
// vocabulary: a session on `command-code-messages` otherwise matched no row
|
|
6790
|
+
// and fell back to index 0, pre-selecting the wrong supplier.
|
|
6791
|
+
const current = this.displayProviderId(this.currentProviderId());
|
|
5775
6792
|
if (providers.length === 0) {
|
|
5776
6793
|
this.pushRow({ kind: 'error', text: t('provider.none') });
|
|
5777
6794
|
this.markDirty();
|
|
@@ -5810,7 +6827,46 @@ export class SshTui {
|
|
|
5810
6827
|
: {};
|
|
5811
6828
|
return rememberedRouteFor(memory, provider);
|
|
5812
6829
|
}
|
|
6830
|
+
/** The route this session is running right now, record-shaped. */
|
|
6831
|
+
sessionRouteRecord() {
|
|
6832
|
+
const current = this.selectionRef?.current;
|
|
6833
|
+
const subagent = this.subagentSelection.current;
|
|
6834
|
+
// Shaped by the launcher's own builder, so what the TUI reports and what the
|
|
6835
|
+
// launcher writes cannot drift apart.
|
|
6836
|
+
return sessionRouteInput({
|
|
6837
|
+
provider: current?.provider ?? this.currentProviderId(),
|
|
6838
|
+
model: current?.model ?? this.agent.options.model ?? '',
|
|
6839
|
+
...(current?.reasoningEffort === undefined ? {} : { reasoningEffort: String(current.reasoningEffort) }),
|
|
6840
|
+
subagent: {
|
|
6841
|
+
...(subagent.provider === undefined ? {} : { provider: subagent.provider }),
|
|
6842
|
+
model: subagent.model,
|
|
6843
|
+
...(subagent.reasoningEffort === undefined ? {} : { reasoningEffort: String(subagent.reasoningEffort) }),
|
|
6844
|
+
},
|
|
6845
|
+
});
|
|
6846
|
+
}
|
|
6847
|
+
/**
|
|
6848
|
+
* Tell the launcher which route this session has settled on, so resuming it
|
|
6849
|
+
* comes back on the same supplier — the subagent route included, which is what
|
|
6850
|
+
* makes a resumed cross-provider conversation reproducible.
|
|
6851
|
+
*
|
|
6852
|
+
* Reported when a route settles (`/model`, `/submodel`, `/setup`) and at the
|
|
6853
|
+
* start of every turn, which catches a preset or a settings edit moving the
|
|
6854
|
+
* route behind the TUI's back. The launcher owns the file; the TUI only says
|
|
6855
|
+
* what changed, and says nothing when nothing did.
|
|
6856
|
+
*/
|
|
6857
|
+
noteSessionRoute() {
|
|
6858
|
+
const route = this.sessionRouteRecord();
|
|
6859
|
+
if (route === undefined)
|
|
6860
|
+
return;
|
|
6861
|
+
if (sameSessionRoute(this.lastSessionRoute, route))
|
|
6862
|
+
return;
|
|
6863
|
+
this.lastSessionRoute = { ...route, updatedAt: Date.now() };
|
|
6864
|
+
this.onRouteSettled?.(route);
|
|
6865
|
+
}
|
|
5813
6866
|
async rememberRoute(selection) {
|
|
6867
|
+
// The session's record is kept even when the settings service is missing:
|
|
6868
|
+
// coming back to this conversation on the same supplier is the point of it.
|
|
6869
|
+
this.noteSessionRoute();
|
|
5814
6870
|
const settings = this.ctx.get('settings');
|
|
5815
6871
|
if (settings === undefined)
|
|
5816
6872
|
return;
|
|
@@ -5827,19 +6883,25 @@ export class SshTui {
|
|
|
5827
6883
|
]);
|
|
5828
6884
|
}
|
|
5829
6885
|
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
5830
|
-
|
|
6886
|
+
// One gateway, one choice: the picker offers the family's merged list, and
|
|
6887
|
+
// the row that actually serves the model is resolved here so the selection
|
|
6888
|
+
// lands on a route whose protocol can send it. The family base id is what
|
|
6889
|
+
// the user sees and what the route memory remembers.
|
|
6890
|
+
const display = this.displayProviderId(provider);
|
|
6891
|
+
const target = this.familyOwnerRow(display, modelId, await this.advertisedFor(display, modelId));
|
|
6892
|
+
if (!(await this.ensureProviderModelConfigured(target, modelId)))
|
|
5831
6893
|
return;
|
|
5832
6894
|
const llm = this.ctx.get('llm');
|
|
5833
6895
|
const current = this.selectionRef?.current;
|
|
5834
6896
|
let effortOptions = [];
|
|
5835
6897
|
try {
|
|
5836
|
-
const info = await llm?.resolveModelInfo(
|
|
6898
|
+
const info = await llm?.resolveModelInfo(target, modelId);
|
|
5837
6899
|
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
5838
6900
|
}
|
|
5839
6901
|
catch {
|
|
5840
6902
|
effortOptions = [];
|
|
5841
6903
|
}
|
|
5842
|
-
if (effortOptions.length === 0 && providerUsesLocalOAuth(
|
|
6904
|
+
if (effortOptions.length === 0 && providerUsesLocalOAuth(target)) {
|
|
5843
6905
|
effortOptions = localOAuthEffortChoices(modelId);
|
|
5844
6906
|
}
|
|
5845
6907
|
const isUndeclared = effortOptions.length === 0;
|
|
@@ -5856,8 +6918,8 @@ export class SshTui {
|
|
|
5856
6918
|
desc: undefined,
|
|
5857
6919
|
})),
|
|
5858
6920
|
];
|
|
5859
|
-
const rememberedEffort = this.rememberedRoute(
|
|
5860
|
-
const currentEffort = current?.provider ===
|
|
6921
|
+
const rememberedEffort = this.rememberedRoute(display)?.reasoningEffort ?? preferredEffort ?? '';
|
|
6922
|
+
const currentEffort = current?.provider === target
|
|
5861
6923
|
? String(current?.reasoningEffort ?? '')
|
|
5862
6924
|
: rememberedEffort;
|
|
5863
6925
|
const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
|
|
@@ -5873,9 +6935,9 @@ export class SshTui {
|
|
|
5873
6935
|
}, 0, 1, currentIndex);
|
|
5874
6936
|
const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
5875
6937
|
if (isUndeclared && effort !== undefined)
|
|
5876
|
-
await this.declareReasoningEffort(
|
|
6938
|
+
await this.declareReasoningEffort(target, modelId, effort);
|
|
5877
6939
|
const next = {
|
|
5878
|
-
provider,
|
|
6940
|
+
provider: target,
|
|
5879
6941
|
model: modelId,
|
|
5880
6942
|
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
5881
6943
|
};
|
|
@@ -5883,23 +6945,23 @@ export class SshTui {
|
|
|
5883
6945
|
this.selectionRef.current = next;
|
|
5884
6946
|
this.onSelectionChanged?.(next);
|
|
5885
6947
|
await this.persistDefaultSelection(next);
|
|
5886
|
-
await this.rememberRoute(next);
|
|
5887
|
-
const kind = describeProviderRoute(
|
|
6948
|
+
await this.rememberRoute({ ...next, provider: display });
|
|
6949
|
+
const kind = describeProviderRoute(display);
|
|
5888
6950
|
const effortText = effort ?? t('effort.defaultShort');
|
|
5889
6951
|
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
5890
6952
|
this.pushRow({
|
|
5891
6953
|
kind: 'system',
|
|
5892
|
-
text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
|
|
6954
|
+
text: t('effort.switchedModel', { kind: kind.kind, provider: display, model: modelId, effort: effortText, note }),
|
|
5893
6955
|
});
|
|
5894
6956
|
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5895
6957
|
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5896
|
-
if (previousProvider !==
|
|
5897
|
-
await this.syncSubagentToProvider(
|
|
5898
|
-
this.clearQuotaForProvider(
|
|
6958
|
+
if (this.displayProviderId(previousProvider) !== display) {
|
|
6959
|
+
await this.syncSubagentToProvider(display, listedIds, true);
|
|
6960
|
+
this.clearQuotaForProvider(target);
|
|
5899
6961
|
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5900
6962
|
}
|
|
5901
6963
|
else {
|
|
5902
|
-
await this.syncSubagentToProvider(
|
|
6964
|
+
await this.syncSubagentToProvider(display, listedIds);
|
|
5903
6965
|
}
|
|
5904
6966
|
this.markDirty();
|
|
5905
6967
|
}
|
|
@@ -5955,11 +7017,23 @@ export class SshTui {
|
|
|
5955
7017
|
* When the parent provider changes (OAuth or API key), keep the subagent
|
|
5956
7018
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
5957
7019
|
* switching to xAI is treated as stale.
|
|
7020
|
+
*
|
|
7021
|
+
* A `/submodel` pin is the user's explicit route, so a parent switch asks
|
|
7022
|
+
* before moving the children: dropping the pin silently spends on a route
|
|
7023
|
+
* they pinned away from, and keeping it silently leaves the children on the
|
|
7024
|
+
* old provider.
|
|
5958
7025
|
*/
|
|
5959
7026
|
async syncSubagentToProvider(provider, listed = [], force = false) {
|
|
7027
|
+
// Children follow the family the user sees, not the protocol row the parent
|
|
7028
|
+
// request happens to use.
|
|
7029
|
+
provider = this.displayProviderId(provider);
|
|
5960
7030
|
const current = this.subagentSelection.current;
|
|
5961
|
-
if (
|
|
5962
|
-
|
|
7031
|
+
if (current.provider !== undefined) {
|
|
7032
|
+
if (!force)
|
|
7033
|
+
return;
|
|
7034
|
+
if (await this.confirmKeepSubagentPin(provider, current))
|
|
7035
|
+
return;
|
|
7036
|
+
}
|
|
5963
7037
|
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
5964
7038
|
return;
|
|
5965
7039
|
let catalog = [...listed];
|
|
@@ -5985,6 +7059,31 @@ export class SshTui {
|
|
|
5985
7059
|
text: t('sub.followed', { provider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
|
|
5986
7060
|
});
|
|
5987
7061
|
}
|
|
7062
|
+
/**
|
|
7063
|
+
* Ask whether a `/submodel` pin survives a parent provider change.
|
|
7064
|
+
*
|
|
7065
|
+
* Returns true when the pin should be kept. Cancelling the dialog keeps it
|
|
7066
|
+
* too: the pin is the user's explicit choice, so only an explicit answer
|
|
7067
|
+
* moves the children onto the new parent route.
|
|
7068
|
+
*/
|
|
7069
|
+
async confirmKeepSubagentPin(provider, current) {
|
|
7070
|
+
const pinned = this.displayProviderId(current.provider ?? '');
|
|
7071
|
+
const keepLabel = t('sub.followKeepLabel', { provider: pinned });
|
|
7072
|
+
const followLabel = t('sub.followSwitchLabel', { provider });
|
|
7073
|
+
const answer = await this.askQuestion({
|
|
7074
|
+
id: 'submodel-follow-pick',
|
|
7075
|
+
question: t('sub.followAsk', { pinned, provider, model: current.model }),
|
|
7076
|
+
options: [
|
|
7077
|
+
{ label: keepLabel, description: t('sub.followKeepHint') },
|
|
7078
|
+
{ label: followLabel, description: t('sub.followSwitchHint', { provider }) },
|
|
7079
|
+
],
|
|
7080
|
+
}, 0, 1, 0);
|
|
7081
|
+
if (answer.selected[0] === followLabel)
|
|
7082
|
+
return false;
|
|
7083
|
+
this.pushRow({ kind: 'system', text: t('sub.pinKept', { pinned, parent: provider }) });
|
|
7084
|
+
this.markDirty();
|
|
7085
|
+
return true;
|
|
7086
|
+
}
|
|
5988
7087
|
clearQuotaForProvider(provider) {
|
|
5989
7088
|
const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
|
|
5990
7089
|
const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
|
|
@@ -6002,6 +7101,13 @@ export class SshTui {
|
|
|
6002
7101
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
6003
7102
|
async saveSubagentSelection(next) {
|
|
6004
7103
|
this.subagentSelection.current = next;
|
|
7104
|
+
// An explicit change makes the settings the source again: the next time
|
|
7105
|
+
// this session is resumed, the record — not a stale in-memory override —
|
|
7106
|
+
// decides, and the settings watcher may update the ref again.
|
|
7107
|
+
this.subagentSelection.source = 'settings';
|
|
7108
|
+
// The session record keeps the subagent route too, so a resumed
|
|
7109
|
+
// cross-provider conversation comes back on the same children.
|
|
7110
|
+
this.noteSessionRoute();
|
|
6005
7111
|
const settings = this.ctx.get('settings');
|
|
6006
7112
|
if (settings === undefined) {
|
|
6007
7113
|
this.pushRow({ kind: 'error', text: t('sub.settingsMissing') });
|
|
@@ -6013,6 +7119,9 @@ export class SshTui {
|
|
|
6013
7119
|
}
|
|
6014
7120
|
/** Resolve the picker model list for one provider (endpoint first, then catalog). */
|
|
6015
7121
|
async subagentModelOptions(provider) {
|
|
7122
|
+
const family = this.providerFamilyRows(provider);
|
|
7123
|
+
if (family.length > 1)
|
|
7124
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
6016
7125
|
const llm = this.ctx.get('llm');
|
|
6017
7126
|
let options = [];
|
|
6018
7127
|
let source = t('model.configured');
|
|
@@ -6058,11 +7167,10 @@ export class SshTui {
|
|
|
6058
7167
|
}
|
|
6059
7168
|
/** /submodel: pick (or set) the model subagent children use. */
|
|
6060
7169
|
async runSubmodelCommand(arg) {
|
|
6061
|
-
const provider = this.effectiveSubagentProvider();
|
|
6062
7170
|
const current = this.subagentSelection.current;
|
|
6063
7171
|
const direct = arg.trim();
|
|
6064
7172
|
if (direct.toLowerCase() === 'reset' || direct === '跟随' || direct === '默认') {
|
|
6065
|
-
const parentProvider = this.currentProviderId();
|
|
7173
|
+
const parentProvider = this.displayProviderId(this.currentProviderId());
|
|
6066
7174
|
const nextModel = defaultSubagentModelForProvider(parentProvider, [], this.selectionRef?.current?.model ?? this.agent.options.model);
|
|
6067
7175
|
const persisted = await this.saveSubagentSelection({
|
|
6068
7176
|
model: nextModel,
|
|
@@ -6075,8 +7183,42 @@ export class SshTui {
|
|
|
6075
7183
|
this.markDirty();
|
|
6076
7184
|
return;
|
|
6077
7185
|
}
|
|
7186
|
+
const slash = direct.indexOf('/');
|
|
7187
|
+
if (slash > 0) {
|
|
7188
|
+
const providerId = direct.slice(0, slash).trim();
|
|
7189
|
+
const modelId = direct.slice(slash + 1).trim();
|
|
7190
|
+
if (providerId !== '' && modelId !== '') {
|
|
7191
|
+
await this.commitSubagentRoute(providerId, modelId, { pinProvider: true });
|
|
7192
|
+
return;
|
|
7193
|
+
}
|
|
7194
|
+
}
|
|
7195
|
+
let provider = this.displayProviderId(this.effectiveSubagentProvider());
|
|
6078
7196
|
let selectedId = direct;
|
|
6079
7197
|
if (selectedId === '') {
|
|
7198
|
+
const parentProvider = this.displayProviderId(this.currentProviderId());
|
|
7199
|
+
const providers = this.listSelectableProviders();
|
|
7200
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
7201
|
+
const pinnedProvider = this.displayProviderId(current.provider ?? '');
|
|
7202
|
+
const pickedAnswer = await this.askQuestion({
|
|
7203
|
+
id: 'submodel-provider-pick',
|
|
7204
|
+
question: t('sub.pickProvider'),
|
|
7205
|
+
options: providers.map(option => ({
|
|
7206
|
+
label: option.label,
|
|
7207
|
+
// The description says what picking the row does. Picking the parent's
|
|
7208
|
+
// provider follows it — that is the one explicit way to unpin without
|
|
7209
|
+
// `/submodel reset` — so the parent row must not read "currently
|
|
7210
|
+
// pinned" just because the pin happens to name it.
|
|
7211
|
+
description: option.id === parentProvider
|
|
7212
|
+
? t('sub.providerFollowsParent')
|
|
7213
|
+
: option.id === pinnedProvider
|
|
7214
|
+
? t('sub.providerPinned')
|
|
7215
|
+
: describeProviderRoute(option.id).kind,
|
|
7216
|
+
})),
|
|
7217
|
+
}, 0, 1, currentIndex);
|
|
7218
|
+
const pickedProvider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
7219
|
+
if (pickedProvider === undefined)
|
|
7220
|
+
return;
|
|
7221
|
+
provider = pickedProvider;
|
|
6080
7222
|
const { options, source } = await this.subagentModelOptions(provider);
|
|
6081
7223
|
if (options.length === 0) {
|
|
6082
7224
|
options.push({ id: current.model, label: current.model });
|
|
@@ -6086,14 +7228,30 @@ export class SshTui {
|
|
|
6086
7228
|
return;
|
|
6087
7229
|
selectedId = selected.id;
|
|
6088
7230
|
}
|
|
6089
|
-
|
|
7231
|
+
await this.commitSubagentRoute(provider, selectedId, {
|
|
7232
|
+
pinProvider: provider !== this.displayProviderId(this.currentProviderId()),
|
|
7233
|
+
});
|
|
7234
|
+
}
|
|
7235
|
+
/** Persist one subagent provider/model pair and say whether it follows or is pinned. */
|
|
7236
|
+
async commitSubagentRoute(provider, modelId, options) {
|
|
7237
|
+
// The picker lists one row per gateway family; the model decides which
|
|
7238
|
+
// protocol row underneath actually serves it.
|
|
7239
|
+
const display = this.displayProviderId(provider);
|
|
7240
|
+
const target = this.familyOwnerRow(display, modelId, await this.advertisedFor(display, modelId));
|
|
7241
|
+
if (!(await this.ensureProviderModelConfigured(target, modelId)))
|
|
6090
7242
|
return;
|
|
6091
|
-
const
|
|
7243
|
+
const effort = this.subagentSelection.current.reasoningEffort;
|
|
7244
|
+
const next = {
|
|
7245
|
+
...(options.pinProvider ? { provider: target } : {}),
|
|
7246
|
+
model: modelId,
|
|
7247
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
7248
|
+
};
|
|
7249
|
+
const persisted = await this.saveSubagentSelection(next);
|
|
6092
7250
|
this.pushRow({
|
|
6093
7251
|
kind: 'system',
|
|
6094
|
-
text: (
|
|
6095
|
-
? t('sub.
|
|
6096
|
-
: t('sub.
|
|
7252
|
+
text: (options.pinProvider
|
|
7253
|
+
? t('sub.modelPinned', { model: modelId, provider: display })
|
|
7254
|
+
: t('sub.modelFollow', { model: modelId, provider: display }))
|
|
6097
7255
|
+ (persisted ? '' : t('sub.sessionOnly')),
|
|
6098
7256
|
});
|
|
6099
7257
|
this.markDirty();
|
|
@@ -6101,6 +7259,7 @@ export class SshTui {
|
|
|
6101
7259
|
/** /effort: pick or set the reasoning effort for the current model. */
|
|
6102
7260
|
async runEffortCommand(arg) {
|
|
6103
7261
|
const provider = this.currentProviderId();
|
|
7262
|
+
const display = this.displayProviderId(provider);
|
|
6104
7263
|
const current = this.selectionRef?.current;
|
|
6105
7264
|
if (current === undefined || current.model === undefined) {
|
|
6106
7265
|
this.pushRow({ kind: 'error', text: t('effort.noModel') });
|
|
@@ -6157,8 +7316,8 @@ export class SshTui {
|
|
|
6157
7316
|
const answer = await this.askQuestion({
|
|
6158
7317
|
id: 'effort-pick',
|
|
6159
7318
|
question: isUndeclared
|
|
6160
|
-
? t('effort.pickCurrentUndeclared', { provider, model: modelId })
|
|
6161
|
-
: t('effort.pickCurrent', { provider, model: modelId }),
|
|
7319
|
+
? t('effort.pickCurrentUndeclared', { provider: display, model: modelId })
|
|
7320
|
+
: t('effort.pickCurrent', { provider: display, model: modelId }),
|
|
6162
7321
|
options: choices.map(c => ({
|
|
6163
7322
|
label: c.label,
|
|
6164
7323
|
description: c.id === currentEffort ? t('disconnect.current') : c.desc,
|
|
@@ -6932,6 +8091,9 @@ export class SshTui {
|
|
|
6932
8091
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
|
|
6933
8092
|
return;
|
|
6934
8093
|
}
|
|
8094
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(-1)) {
|
|
8095
|
+
return;
|
|
8096
|
+
}
|
|
6935
8097
|
else if (this.suggestionsVisible()) {
|
|
6936
8098
|
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
6937
8099
|
this.markDirty();
|
|
@@ -6953,6 +8115,9 @@ export class SshTui {
|
|
|
6953
8115
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
|
|
6954
8116
|
return;
|
|
6955
8117
|
}
|
|
8118
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(1)) {
|
|
8119
|
+
return;
|
|
8120
|
+
}
|
|
6956
8121
|
else if (this.suggestionsVisible()) {
|
|
6957
8122
|
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
6958
8123
|
this.markDirty();
|
|
@@ -7408,8 +8573,8 @@ export class SshTui {
|
|
|
7408
8573
|
const templates = providerTemplates();
|
|
7409
8574
|
const templateEntries = [
|
|
7410
8575
|
{ key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
|
|
7411
|
-
{ key: 'template:opencode-go', label: templates['opencode-go'].label, detail: '
|
|
7412
|
-
{ key: 'template:command-code', label: templates['command-code'].label, detail: '
|
|
8576
|
+
{ key: 'template:opencode-go', label: templates['opencode-go'].label, detail: t('onboard.protocolAuto') },
|
|
8577
|
+
{ key: 'template:command-code', label: templates['command-code'].label, detail: t('onboard.protocolAuto') },
|
|
7413
8578
|
{ key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
|
|
7414
8579
|
{ key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
|
|
7415
8580
|
{ key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
|
|
@@ -7456,6 +8621,108 @@ export class SshTui {
|
|
|
7456
8621
|
}
|
|
7457
8622
|
return sized;
|
|
7458
8623
|
}
|
|
8624
|
+
/**
|
|
8625
|
+
* Leave the models step, after one best-effort trip to the gateway for what
|
|
8626
|
+
* the picks are still missing: the context window / output cap of every model
|
|
8627
|
+
* the listing has not described yet, and — for a gateway that spreads over
|
|
8628
|
+
* several protocols — the endpoints each picked model answers.
|
|
8629
|
+
*
|
|
8630
|
+
* `Ctrl+F` stays as it was, but it is no longer the only way to learn these:
|
|
8631
|
+
* a model typed by hand would otherwise be saved with no capacity at all, and
|
|
8632
|
+
* with no endpoint data the split had nothing to file it by.
|
|
8633
|
+
*/
|
|
8634
|
+
async finishModelsStep(state) {
|
|
8635
|
+
if (this.onboarding !== state || !isModelsStep(state.step))
|
|
8636
|
+
return;
|
|
8637
|
+
if (this.finishingModels.has(state))
|
|
8638
|
+
return;
|
|
8639
|
+
this.finishingModels.add(state);
|
|
8640
|
+
try {
|
|
8641
|
+
const previousStatus = this.status;
|
|
8642
|
+
if (this.onboardingWantsEndpoint(state)) {
|
|
8643
|
+
const fetching = t('onboard.fetchingModels');
|
|
8644
|
+
this.status = fetching;
|
|
8645
|
+
this.markDirty();
|
|
8646
|
+
await this.enrichOnboardingModels(state);
|
|
8647
|
+
// Only take back the line this step put up: a turn that started while
|
|
8648
|
+
// the listing was in flight owns the status now, and restoring the
|
|
8649
|
+
// pre-fetch value would erase "turn 7 running" behind its back.
|
|
8650
|
+
if (this.status === fetching)
|
|
8651
|
+
this.status = previousStatus;
|
|
8652
|
+
}
|
|
8653
|
+
// The wizard may have been cancelled or reset while the fetch was in flight.
|
|
8654
|
+
if (this.onboarding !== state || !isModelsStep(state.step))
|
|
8655
|
+
return;
|
|
8656
|
+
// Size the picks before the confirm step so the route default the wizard
|
|
8657
|
+
// persists is derived, not guessed: the listing first, the installed
|
|
8658
|
+
// catalog second. Only when some pick stayed unsized does the route
|
|
8659
|
+
// default matter, and only then is a step shown for it.
|
|
8660
|
+
const sized = this.sizeOnboardingModels(state);
|
|
8661
|
+
const unsized = state.models.filter(id => !sized.has(id));
|
|
8662
|
+
if (unsized.length === 0) {
|
|
8663
|
+
state.step = 'confirm';
|
|
8664
|
+
}
|
|
8665
|
+
else {
|
|
8666
|
+
state.routeContextWindow = suggestedRouteContextWindow(sized.values())
|
|
8667
|
+
?? HARNESS_DEFAULT_CONTEXT_WINDOW;
|
|
8668
|
+
state.step = 'context';
|
|
8669
|
+
}
|
|
8670
|
+
this.input = '';
|
|
8671
|
+
this.cursor = 0;
|
|
8672
|
+
this.markDirty();
|
|
8673
|
+
}
|
|
8674
|
+
finally {
|
|
8675
|
+
this.finishingModels.delete(state);
|
|
8676
|
+
}
|
|
8677
|
+
}
|
|
8678
|
+
/** Whether leaving the models step has anything left to ask the gateway for. */
|
|
8679
|
+
onboardingWantsEndpoint(state) {
|
|
8680
|
+
if (onboardTemplate(state).protocols !== undefined && state.modelEndpoints === undefined)
|
|
8681
|
+
return true;
|
|
8682
|
+
return state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
|
|
8683
|
+
}
|
|
8684
|
+
/** Fill the picks' capacities and endpoints from the gateway; never fatal. */
|
|
8685
|
+
async enrichOnboardingModels(state) {
|
|
8686
|
+
const template = onboardTemplate(state);
|
|
8687
|
+
const baseURL = state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl;
|
|
8688
|
+
if (baseURL === '')
|
|
8689
|
+
return;
|
|
8690
|
+
const wantsEndpoints = template.protocols !== undefined && state.modelEndpoints === undefined;
|
|
8691
|
+
const wantsCapacity = state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
|
|
8692
|
+
const llm = this.ctx.get('llm');
|
|
8693
|
+
if (wantsCapacity && llm !== undefined) {
|
|
8694
|
+
try {
|
|
8695
|
+
const discovered = await discoverProviderModels(llm, {
|
|
8696
|
+
baseURL,
|
|
8697
|
+
...(template.api === undefined ? {} : { api: template.api }),
|
|
8698
|
+
...(state.key === '' ? {} : { apiKey: state.key }),
|
|
8699
|
+
}, AbortSignal.timeout(8_000));
|
|
8700
|
+
state.modelCapacity ??= new Map();
|
|
8701
|
+
for (const model of discovered) {
|
|
8702
|
+
if (!state.models.includes(model.id))
|
|
8703
|
+
continue;
|
|
8704
|
+
const capacity = { ...(state.modelCapacity.get(model.id) ?? {}) };
|
|
8705
|
+
if (capacity.contextWindow === undefined && model.contextWindow !== undefined) {
|
|
8706
|
+
capacity.contextWindow = model.contextWindow;
|
|
8707
|
+
}
|
|
8708
|
+
if (capacity.maxTokens === undefined && model.maxTokens !== undefined) {
|
|
8709
|
+
capacity.maxTokens = model.maxTokens;
|
|
8710
|
+
}
|
|
8711
|
+
if (Object.keys(capacity).length > 0)
|
|
8712
|
+
state.modelCapacity.set(model.id, capacity);
|
|
8713
|
+
}
|
|
8714
|
+
}
|
|
8715
|
+
catch {
|
|
8716
|
+
// The listing is an enrichment, not a gate: the installed catalog and
|
|
8717
|
+
// the route default still cover a gateway that cannot be listed.
|
|
8718
|
+
}
|
|
8719
|
+
}
|
|
8720
|
+
if (wantsEndpoints) {
|
|
8721
|
+
const endpoints = await fetchModelEndpoints(baseURL, state.key);
|
|
8722
|
+
if (endpoints !== undefined)
|
|
8723
|
+
state.modelEndpoints = endpoints;
|
|
8724
|
+
}
|
|
8725
|
+
}
|
|
7459
8726
|
handleOnboardingChar(text) {
|
|
7460
8727
|
const state = this.onboarding;
|
|
7461
8728
|
if (state === undefined)
|
|
@@ -7515,7 +8782,77 @@ export class SshTui {
|
|
|
7515
8782
|
case 'base-url':
|
|
7516
8783
|
case 'key':
|
|
7517
8784
|
case 'models':
|
|
8785
|
+
case 'models-pick':
|
|
8786
|
+
case 'model-default':
|
|
7518
8787
|
case 'context': {
|
|
8788
|
+
if (state.step === 'models-pick') {
|
|
8789
|
+
// The step is already being left (the gateway listing is in flight):
|
|
8790
|
+
// the pick is decided, so a stray key must not reopen it.
|
|
8791
|
+
if (this.finishingModels.has(state))
|
|
8792
|
+
return;
|
|
8793
|
+
const total = state.modelCandidates?.length ?? 0;
|
|
8794
|
+
if (total === 0) {
|
|
8795
|
+
state.step = 'models';
|
|
8796
|
+
this.markDirty();
|
|
8797
|
+
return;
|
|
8798
|
+
}
|
|
8799
|
+
const checked = state.modelChecked ?? (state.modelChecked = new Set());
|
|
8800
|
+
if (text === '\r' || text === '\n') {
|
|
8801
|
+
const picked = this.checkedOnboardingModels(state);
|
|
8802
|
+
if (picked.length === 0) {
|
|
8803
|
+
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
8804
|
+
this.markDirty();
|
|
8805
|
+
return;
|
|
8806
|
+
}
|
|
8807
|
+
state.models = picked;
|
|
8808
|
+
state.defaultModel = picked[0];
|
|
8809
|
+
if (picked.length > 1) {
|
|
8810
|
+
state.step = 'model-default';
|
|
8811
|
+
state.modelCursor = 0;
|
|
8812
|
+
this.markDirty();
|
|
8813
|
+
return;
|
|
8814
|
+
}
|
|
8815
|
+
this.advanceOnboarding();
|
|
8816
|
+
return;
|
|
8817
|
+
}
|
|
8818
|
+
if (text === ' ' || text === ' ') {
|
|
8819
|
+
const index = state.modelCursor ?? 0;
|
|
8820
|
+
if (checked.has(index))
|
|
8821
|
+
checked.delete(index);
|
|
8822
|
+
else
|
|
8823
|
+
checked.add(index);
|
|
8824
|
+
this.markDirty();
|
|
8825
|
+
return;
|
|
8826
|
+
}
|
|
8827
|
+
const hotkey = questionOptionIndex(text, total);
|
|
8828
|
+
if (hotkey !== undefined) {
|
|
8829
|
+
state.modelCursor = hotkey;
|
|
8830
|
+
if (checked.has(hotkey))
|
|
8831
|
+
checked.delete(hotkey);
|
|
8832
|
+
else
|
|
8833
|
+
checked.add(hotkey);
|
|
8834
|
+
this.markDirty();
|
|
8835
|
+
}
|
|
8836
|
+
return;
|
|
8837
|
+
}
|
|
8838
|
+
if (state.step === 'model-default') {
|
|
8839
|
+
if (this.finishingModels.has(state))
|
|
8840
|
+
return;
|
|
8841
|
+
const picked = this.checkedOnboardingModels(state);
|
|
8842
|
+
if (text === '\r' || text === '\n') {
|
|
8843
|
+
const chosen = picked[Math.min(state.modelCursor ?? 0, Math.max(0, picked.length - 1))];
|
|
8844
|
+
if (chosen !== undefined)
|
|
8845
|
+
state.defaultModel = chosen;
|
|
8846
|
+
this.advanceOnboarding();
|
|
8847
|
+
return;
|
|
8848
|
+
}
|
|
8849
|
+
const hotkey = questionOptionIndex(text, picked.length);
|
|
8850
|
+
if (hotkey !== undefined) {
|
|
8851
|
+
state.modelCursor = hotkey;
|
|
8852
|
+
this.markDirty();
|
|
8853
|
+
}
|
|
8854
|
+
return;
|
|
8855
|
+
}
|
|
7519
8856
|
if (state.step === 'models' && text === '\x06') {
|
|
7520
8857
|
void this.fetchOnboardingModels();
|
|
7521
8858
|
return;
|
|
@@ -7541,19 +8878,26 @@ export class SshTui {
|
|
|
7541
8878
|
state.key = value;
|
|
7542
8879
|
}
|
|
7543
8880
|
else if (state.step === 'models') {
|
|
7544
|
-
|
|
7545
|
-
//
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
:
|
|
8881
|
+
// Leaving the step is already under way (the gateway listing is in
|
|
8882
|
+
// flight): a second Enter must not restart it or reopen the picker.
|
|
8883
|
+
if (this.finishingModels.has(state))
|
|
8884
|
+
return;
|
|
8885
|
+
if (value === '') {
|
|
8886
|
+
// The default answer is a choice, not "take everything the listing
|
|
8887
|
+
// returned": the picker shows the models and lets the user pick
|
|
8888
|
+
// which to configure, then which one to run. A typed list stays
|
|
8889
|
+
// the power-user path and skips both questions.
|
|
8890
|
+
this.openOnboardingModelPicker(state);
|
|
8891
|
+
return;
|
|
8892
|
+
}
|
|
8893
|
+
const parsed = value.split(/[\s,,]+/u).filter(Boolean);
|
|
7551
8894
|
if (parsed.length === 0) {
|
|
7552
8895
|
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
7553
8896
|
this.markDirty();
|
|
7554
8897
|
return;
|
|
7555
8898
|
}
|
|
7556
8899
|
state.models = parsed;
|
|
8900
|
+
state.defaultModel = parsed[0];
|
|
7557
8901
|
}
|
|
7558
8902
|
else if (state.step === 'context') {
|
|
7559
8903
|
// Enter keeps the pre-filled value; a typed number overrides it.
|
|
@@ -7600,6 +8944,12 @@ export class SshTui {
|
|
|
7600
8944
|
state.baseUrl = '';
|
|
7601
8945
|
state.key = '';
|
|
7602
8946
|
state.models = [];
|
|
8947
|
+
// A restart must not inherit the previous run's picker: a stale
|
|
8948
|
+
// `modelChecked` would pre-tick models from the provider being left.
|
|
8949
|
+
state.defaultModel = undefined;
|
|
8950
|
+
state.modelCandidates = [];
|
|
8951
|
+
state.modelChecked = new Set();
|
|
8952
|
+
state.modelCursor = 0;
|
|
7603
8953
|
state.modelCapacity = new Map();
|
|
7604
8954
|
this.input = '';
|
|
7605
8955
|
this.cursor = 0;
|
|
@@ -7624,21 +8974,11 @@ export class SshTui {
|
|
|
7624
8974
|
else if (state.step === 'key') {
|
|
7625
8975
|
state.step = 'models';
|
|
7626
8976
|
}
|
|
7627
|
-
else if (state.step
|
|
7628
|
-
//
|
|
7629
|
-
//
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
const sized = this.sizeOnboardingModels(state);
|
|
7633
|
-
const unsized = state.models.filter(id => !sized.has(id));
|
|
7634
|
-
if (unsized.length === 0) {
|
|
7635
|
-
state.step = 'confirm';
|
|
7636
|
-
}
|
|
7637
|
-
else {
|
|
7638
|
-
state.routeContextWindow = suggestedRouteContextWindow(sized.values())
|
|
7639
|
-
?? HARNESS_DEFAULT_CONTEXT_WINDOW;
|
|
7640
|
-
state.step = 'context';
|
|
7641
|
-
}
|
|
8977
|
+
else if (isModelsStep(state.step)) {
|
|
8978
|
+
// Leaving the models step is asynchronous: the gateway is asked once more
|
|
8979
|
+
// for whatever the picks are still missing (see finishModelsStep).
|
|
8980
|
+
void this.finishModelsStep(state);
|
|
8981
|
+
return;
|
|
7642
8982
|
}
|
|
7643
8983
|
else if (state.step === 'context') {
|
|
7644
8984
|
state.step = 'confirm';
|
|
@@ -7647,6 +8987,92 @@ export class SshTui {
|
|
|
7647
8987
|
this.cursor = 0;
|
|
7648
8988
|
this.markDirty();
|
|
7649
8989
|
}
|
|
8990
|
+
/**
|
|
8991
|
+
* Open the models picker: every model the listing offered — or the template's
|
|
8992
|
+
* pinned ones when nothing was listed — with the template's models checked.
|
|
8993
|
+
*
|
|
8994
|
+
* Enter used to accept the whole listing, which quietly wrote every model of
|
|
8995
|
+
* the gateway into settings and made the session's model whichever id came
|
|
8996
|
+
* first. The picker makes both the set and the session's model explicit; a
|
|
8997
|
+
* typed list still skips it.
|
|
8998
|
+
*/
|
|
8999
|
+
openOnboardingModelPicker(state) {
|
|
9000
|
+
const template = onboardTemplate(state);
|
|
9001
|
+
const candidates = [...new Set((state.models.length > 0 ? state.models : template.defaultModels).filter(id => id !== ''))];
|
|
9002
|
+
if (candidates.length === 0) {
|
|
9003
|
+
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
9004
|
+
this.markDirty();
|
|
9005
|
+
return;
|
|
9006
|
+
}
|
|
9007
|
+
const pinned = new Set(template.defaultModels);
|
|
9008
|
+
const checked = new Set();
|
|
9009
|
+
candidates.forEach((id, index) => {
|
|
9010
|
+
if (pinned.has(id))
|
|
9011
|
+
checked.add(index);
|
|
9012
|
+
});
|
|
9013
|
+
if (checked.size === 0)
|
|
9014
|
+
checked.add(0);
|
|
9015
|
+
state.modelCandidates = candidates;
|
|
9016
|
+
state.modelChecked = checked;
|
|
9017
|
+
state.modelCursor = Math.min(...checked);
|
|
9018
|
+
state.step = 'models-pick';
|
|
9019
|
+
this.input = '';
|
|
9020
|
+
this.cursor = 0;
|
|
9021
|
+
this.markDirty();
|
|
9022
|
+
}
|
|
9023
|
+
/** The checked models of the wizard's picker, in listing order. */
|
|
9024
|
+
checkedOnboardingModels(state) {
|
|
9025
|
+
const candidates = state.modelCandidates ?? [];
|
|
9026
|
+
return [...(state.modelChecked ?? new Set())]
|
|
9027
|
+
.sort((left, right) => left - right)
|
|
9028
|
+
.map(index => candidates[index])
|
|
9029
|
+
.filter((id) => id !== undefined);
|
|
9030
|
+
}
|
|
9031
|
+
/** Move the highlight in either picker step. */
|
|
9032
|
+
moveOnboardingModelCursor(delta) {
|
|
9033
|
+
const state = this.onboarding;
|
|
9034
|
+
if (state === undefined)
|
|
9035
|
+
return false;
|
|
9036
|
+
if (state.step === 'models-pick') {
|
|
9037
|
+
const total = state.modelCandidates?.length ?? 0;
|
|
9038
|
+
if (total === 0)
|
|
9039
|
+
return false;
|
|
9040
|
+
state.modelCursor = Math.max(0, Math.min(total - 1, (state.modelCursor ?? 0) + delta));
|
|
9041
|
+
this.markDirty();
|
|
9042
|
+
return true;
|
|
9043
|
+
}
|
|
9044
|
+
if (state.step === 'model-default') {
|
|
9045
|
+
const picked = this.checkedOnboardingModels(state);
|
|
9046
|
+
if (picked.length === 0)
|
|
9047
|
+
return false;
|
|
9048
|
+
state.modelCursor = Math.max(0, Math.min(picked.length - 1, (state.modelCursor ?? 0) + delta));
|
|
9049
|
+
this.markDirty();
|
|
9050
|
+
return true;
|
|
9051
|
+
}
|
|
9052
|
+
return false;
|
|
9053
|
+
}
|
|
9054
|
+
/**
|
|
9055
|
+
* Esc in a picker step goes back one step instead of throwing the wizard
|
|
9056
|
+
* away — the listing is the expensive part and the user is still choosing.
|
|
9057
|
+
*/
|
|
9058
|
+
stepBackOnboarding() {
|
|
9059
|
+
const state = this.onboarding;
|
|
9060
|
+
if (state === undefined)
|
|
9061
|
+
return false;
|
|
9062
|
+
if (state.step === 'models-pick') {
|
|
9063
|
+
state.step = 'models';
|
|
9064
|
+
this.markDirty();
|
|
9065
|
+
return true;
|
|
9066
|
+
}
|
|
9067
|
+
if (state.step === 'model-default') {
|
|
9068
|
+
state.step = 'models-pick';
|
|
9069
|
+
const checked = [...(state.modelChecked ?? new Set())];
|
|
9070
|
+
state.modelCursor = checked.length === 0 ? 0 : Math.min(...checked);
|
|
9071
|
+
this.markDirty();
|
|
9072
|
+
return true;
|
|
9073
|
+
}
|
|
9074
|
+
return false;
|
|
9075
|
+
}
|
|
7650
9076
|
/** Fetch the endpoint's model list into the onboarding wizard's models step. */
|
|
7651
9077
|
async fetchOnboardingModels() {
|
|
7652
9078
|
const state = this.onboarding;
|
|
@@ -7691,6 +9117,11 @@ export class SshTui {
|
|
|
7691
9117
|
this.pushRow({ kind: 'error', text: t('onboard.noModels') });
|
|
7692
9118
|
}
|
|
7693
9119
|
else {
|
|
9120
|
+
// Read the gateway's own route list while the key is still in hand: the
|
|
9121
|
+
// confirm step has no way to ask for it again.
|
|
9122
|
+
if (template.protocols !== undefined) {
|
|
9123
|
+
state.modelEndpoints = await fetchModelEndpoints(baseURL, key);
|
|
9124
|
+
}
|
|
7694
9125
|
state.models = ids;
|
|
7695
9126
|
state.modelCapacity = new Map(discovered.flatMap(model => {
|
|
7696
9127
|
const capacity = {
|
|
@@ -7716,6 +9147,10 @@ export class SshTui {
|
|
|
7716
9147
|
const state = this.onboarding;
|
|
7717
9148
|
if (state === undefined)
|
|
7718
9149
|
return;
|
|
9150
|
+
// `/setup` can switch the parent route (that is the point of adding a key),
|
|
9151
|
+
// so remember where it pointed before the wizard's answer is applied: the
|
|
9152
|
+
// subagent pin question below needs the old value.
|
|
9153
|
+
const previousParentProvider = this.currentProviderId();
|
|
7719
9154
|
let saved = true;
|
|
7720
9155
|
try {
|
|
7721
9156
|
const credentials = this.ctx.get('credentials');
|
|
@@ -7724,14 +9159,14 @@ export class SshTui {
|
|
|
7724
9159
|
if (state.providerType === 'official') {
|
|
7725
9160
|
const envRef = 'DEEPSEEK_API_KEY';
|
|
7726
9161
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7727
|
-
const model = state.models[0] ?? 'deepseek-v4-pro';
|
|
9162
|
+
const model = state.defaultModel ?? state.models[0] ?? 'deepseek-v4-pro';
|
|
7728
9163
|
await this.ctx.get('agentDefaultModel')?.saveSelection({ provider: 'deepseek-official', model });
|
|
7729
9164
|
if (this.selectionRef !== undefined) {
|
|
7730
9165
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
7731
9166
|
}
|
|
7732
9167
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
7733
9168
|
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
7734
|
-
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
9169
|
+
await this.syncSubagentToProvider('deepseek-official', state.models, previousParentProvider !== 'deepseek-official');
|
|
7735
9170
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
7736
9171
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
7737
9172
|
this.pushRow({ kind: 'system', text: t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }) });
|
|
@@ -7745,7 +9180,7 @@ export class SshTui {
|
|
|
7745
9180
|
}
|
|
7746
9181
|
else {
|
|
7747
9182
|
const envRef = envRefForId(state.providerId);
|
|
7748
|
-
const model = state.models[0];
|
|
9183
|
+
const model = state.defaultModel ?? state.models[0];
|
|
7749
9184
|
// OpenCode / third-party (llm-pi-ai) routes have no adapter-level
|
|
7750
9185
|
// reasoning default. Re-running setup must not silently drop the
|
|
7751
9186
|
// effort that makes thinking arrive as `reasoning` blocks; default it
|
|
@@ -7761,7 +9196,44 @@ export class SshTui {
|
|
|
7761
9196
|
: await defaultReasoningEffort(llm, state.providerId, model);
|
|
7762
9197
|
const reasoningEfforts = onboardingReasoningEfforts(state.providerType, defaultEffort === undefined ? undefined : String(defaultEffort));
|
|
7763
9198
|
const existing = this.piAiProviderProfile(state.providerId);
|
|
7764
|
-
|
|
9199
|
+
// One gateway can already be spread over sibling entries (an earlier
|
|
9200
|
+
// setup, or a hand-written profile): the pool is every model the gateway
|
|
9201
|
+
// has, so re-running setup re-homes them instead of duplicating or
|
|
9202
|
+
// dropping any.
|
|
9203
|
+
const baseProtocol = declaredProtocol(existing?.api) ?? template.api ?? 'openai-completions';
|
|
9204
|
+
const siblingEntries = template.protocols === undefined
|
|
9205
|
+
? []
|
|
9206
|
+
: template.protocols
|
|
9207
|
+
.filter(protocol => protocol !== baseProtocol)
|
|
9208
|
+
.map(protocol => ({
|
|
9209
|
+
protocol,
|
|
9210
|
+
profile: this.piAiProviderProfile(siblingProviderId(state.providerId, protocol)),
|
|
9211
|
+
}));
|
|
9212
|
+
const existingModels = [
|
|
9213
|
+
...(Array.isArray(existing?.models) ? existing.models : []),
|
|
9214
|
+
...siblingEntries.flatMap(entry => (Array.isArray(entry.profile?.models) ? entry.profile.models : [])),
|
|
9215
|
+
];
|
|
9216
|
+
// Where each model already sits, so the split re-homes only what the
|
|
9217
|
+
// gateway itself says moved: a working placement is never relocated by
|
|
9218
|
+
// the table, and `/setup` keeps the promise that an existing model list
|
|
9219
|
+
// only grows.
|
|
9220
|
+
const modelPlacement = new Map();
|
|
9221
|
+
const recordPlacement = (models, protocol) => {
|
|
9222
|
+
if (!Array.isArray(models))
|
|
9223
|
+
return;
|
|
9224
|
+
for (const raw of models) {
|
|
9225
|
+
const id = typeof raw === 'string'
|
|
9226
|
+
? raw
|
|
9227
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
9228
|
+
? raw.id
|
|
9229
|
+
: '';
|
|
9230
|
+
if (id !== '' && !modelPlacement.has(id))
|
|
9231
|
+
modelPlacement.set(id, protocol);
|
|
9232
|
+
}
|
|
9233
|
+
};
|
|
9234
|
+
recordPlacement(existing?.models, baseProtocol);
|
|
9235
|
+
for (const entry of siblingEntries)
|
|
9236
|
+
recordPlacement(entry.profile?.models, entry.protocol);
|
|
7765
9237
|
// The confirm step can be reached without walking the models step (and
|
|
7766
9238
|
// a saved catalog answer may have landed after it), so size the picks
|
|
7767
9239
|
// here too: the call is idempotent and fills only missing windows.
|
|
@@ -7790,36 +9262,74 @@ export class SshTui {
|
|
|
7790
9262
|
// and an absent key defers to the provider's own environment auth.
|
|
7791
9263
|
const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
|
|
7792
9264
|
const keyless = catalogRoute && state.key === '';
|
|
7793
|
-
const
|
|
9265
|
+
const baseURL = state.baseUrl === ''
|
|
9266
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
9267
|
+
: state.baseUrl;
|
|
9268
|
+
// A gateway with `protocols` keeps one wizard row and lands in several
|
|
9269
|
+
// provider entries, one per protocol its models actually speak. The base
|
|
9270
|
+
// id keeps the protocol it already declares (so routes naming it never
|
|
9271
|
+
// change meaning); a fresh gateway takes the template's pinned one.
|
|
9272
|
+
const split = template.protocols === undefined
|
|
9273
|
+
? new Map([[baseProtocol, mergedIds]])
|
|
9274
|
+
: splitModelsByProtocol({
|
|
9275
|
+
models: mergedIds,
|
|
9276
|
+
...(state.modelEndpoints === undefined ? {} : { advertised: state.modelEndpoints }),
|
|
9277
|
+
...(modelPlacement.size === 0 ? {} : { existing: modelPlacement }),
|
|
9278
|
+
...(gatewayModelTable(baseURL) === undefined ? {} : { table: gatewayModelTable(baseURL) }),
|
|
9279
|
+
fallback: baseProtocol,
|
|
9280
|
+
preference: template.protocols,
|
|
9281
|
+
});
|
|
9282
|
+
const profileFor = (protocol, models) => ({
|
|
7794
9283
|
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
7795
9284
|
? existing.displayName
|
|
7796
9285
|
: template.label,
|
|
7797
9286
|
...(keyless ? {} : { apiKeyEnv: envRef }),
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
: state.baseUrl,
|
|
7805
|
-
}),
|
|
7806
|
-
models: mergedIds.map(id => ({
|
|
9287
|
+
// A catalog route leaves the protocol to the installed catalog: it
|
|
9288
|
+
// used to write no `api` at all, and pinning one here would override
|
|
9289
|
+
// whatever that catalog serves.
|
|
9290
|
+
api: template.protocols === undefined ? (template.api ?? existing?.api) : protocol,
|
|
9291
|
+
...(catalogRoute && state.baseUrl === '' ? {} : { baseURL }),
|
|
9292
|
+
models: models.map(id => ({
|
|
7807
9293
|
id,
|
|
7808
9294
|
...(state.modelCapacity?.get(id) ?? template.defaultModelCapacity?.[id] ?? {}),
|
|
7809
9295
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
7810
9296
|
})),
|
|
7811
9297
|
...(state.routeContextWindow === undefined ? {} : { defaultContextWindow: state.routeContextWindow }),
|
|
7812
9298
|
...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
|
|
7813
|
-
};
|
|
9299
|
+
});
|
|
9300
|
+
// Write every protocol that ends up holding models, plus any sibling
|
|
9301
|
+
// that exists but is now empty because its models moved elsewhere.
|
|
9302
|
+
const writes = [...split]
|
|
9303
|
+
.filter(([, models]) => models.length > 0)
|
|
9304
|
+
.map(([protocol, models]) => ({ protocol, models }));
|
|
9305
|
+
for (const sibling of siblingEntries) {
|
|
9306
|
+
// Only an entry that exists can be emptied: never create a sibling
|
|
9307
|
+
// just to say it holds nothing.
|
|
9308
|
+
if (sibling.profile === undefined)
|
|
9309
|
+
continue;
|
|
9310
|
+
if (!writes.some(entry => entry.protocol === sibling.protocol)) {
|
|
9311
|
+
writes.push({ protocol: sibling.protocol, models: [] });
|
|
9312
|
+
}
|
|
9313
|
+
}
|
|
9314
|
+
const entryId = (protocol) => protocol === baseProtocol ? state.providerId : siblingProviderId(state.providerId, protocol);
|
|
9315
|
+
// The route the wizard hands over must be the entry that actually lists
|
|
9316
|
+
// the chosen model — with a split they are no longer the same id.
|
|
9317
|
+
const ownerId = entryId(writes.find(entry => entry.models.includes(model))?.protocol ?? baseProtocol);
|
|
7814
9318
|
if (settings === undefined) {
|
|
7815
9319
|
this.pushRow({ kind: 'error', text: t('onboard.settingsMissing') });
|
|
7816
9320
|
saved = false;
|
|
7817
9321
|
}
|
|
7818
9322
|
else {
|
|
7819
|
-
await settings.mutate(settingsNamespace('llm-pi-ai'),
|
|
7820
|
-
|
|
7821
|
-
|
|
9323
|
+
await settings.mutate(settingsNamespace('llm-pi-ai'), writes.map(entry => ({
|
|
9324
|
+
op: 'set',
|
|
9325
|
+
path: ['providers', entryId(entry.protocol)],
|
|
9326
|
+
value: profileFor(entry.protocol, entry.models),
|
|
9327
|
+
})));
|
|
7822
9328
|
this.pushRow({ kind: 'system', text: t('onboard.providerSaved', { id: state.providerId, path: displayDshPath('settings.yaml') }) });
|
|
9329
|
+
if (template.protocols !== undefined) {
|
|
9330
|
+
const summary = writes.map(entry => `${GATEWAY_PROTOCOL_SUFFIX[entry.protocol]} ${entry.models.length}`).join(' · ');
|
|
9331
|
+
this.pushRow({ kind: 'system', text: t('onboard.providerSplit', { id: state.providerId, summary }) });
|
|
9332
|
+
}
|
|
7823
9333
|
}
|
|
7824
9334
|
// Only store the key when its provider profile actually made it to
|
|
7825
9335
|
// settings; otherwise the saved key points at an unusable route.
|
|
@@ -7827,7 +9337,7 @@ export class SshTui {
|
|
|
7827
9337
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7828
9338
|
if (saved) {
|
|
7829
9339
|
const selection = {
|
|
7830
|
-
provider:
|
|
9340
|
+
provider: ownerId,
|
|
7831
9341
|
model,
|
|
7832
9342
|
...(defaultEffort === undefined ? {} : { reasoningEffort: defaultEffort }),
|
|
7833
9343
|
};
|
|
@@ -7837,10 +9347,10 @@ export class SshTui {
|
|
|
7837
9347
|
}
|
|
7838
9348
|
this.onSelectionChanged?.(selection);
|
|
7839
9349
|
await this.rememberRoute(selection);
|
|
7840
|
-
await this.syncSubagentToProvider(
|
|
9350
|
+
await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
|
|
7841
9351
|
this.pushRow({
|
|
7842
9352
|
kind: 'system',
|
|
7843
|
-
text: t('onboard.customDone', { id:
|
|
9353
|
+
text: t('onboard.customDone', { id: ownerId, model }),
|
|
7844
9354
|
});
|
|
7845
9355
|
}
|
|
7846
9356
|
}
|
|
@@ -7990,8 +9500,12 @@ export class SshTui {
|
|
|
7990
9500
|
}
|
|
7991
9501
|
if (this.dialog.kind === 'confirm')
|
|
7992
9502
|
this.closeConfirm('cancel');
|
|
7993
|
-
else if (this.dialog.kind === 'onboarding')
|
|
7994
|
-
|
|
9503
|
+
else if (this.dialog.kind === 'onboarding') {
|
|
9504
|
+
// Inside the models picker Esc steps back through the wizard's own
|
|
9505
|
+
// steps; anywhere else it still abandons the wizard.
|
|
9506
|
+
if (!this.stepBackOnboarding())
|
|
9507
|
+
this.cancelOnboarding();
|
|
9508
|
+
}
|
|
7995
9509
|
else
|
|
7996
9510
|
this.dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
7997
9511
|
return;
|
|
@@ -8277,6 +9791,10 @@ export class SshTui {
|
|
|
8277
9791
|
const text = this.input.trim();
|
|
8278
9792
|
if (text === '')
|
|
8279
9793
|
return;
|
|
9794
|
+
// Framed mode paints the composer; line mode has to put the typed line
|
|
9795
|
+
// into the log itself, or a `tee` never sees what the user sent.
|
|
9796
|
+
if (this.lineMode)
|
|
9797
|
+
this.pushRow({ kind: 'user', text: t('line.prompt', { text }) });
|
|
8280
9798
|
if (text.startsWith('/')) {
|
|
8281
9799
|
this.historyIndex = this.history.length;
|
|
8282
9800
|
this.historyDraft = '';
|
|
@@ -8491,7 +10009,7 @@ export class SshTui {
|
|
|
8491
10009
|
...(quota === undefined ? {} : { quota }),
|
|
8492
10010
|
...(this.contextPressure === undefined ? {} : { context: this.contextPressure }),
|
|
8493
10011
|
parentModel: model,
|
|
8494
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
10012
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
8495
10013
|
subModel: sub.model,
|
|
8496
10014
|
cwd: this.workspaceCwd(),
|
|
8497
10015
|
});
|
|
@@ -8555,16 +10073,20 @@ export class SshTui {
|
|
|
8555
10073
|
this.pushRow({ kind: 'system', text: t('sub.none') });
|
|
8556
10074
|
}
|
|
8557
10075
|
else {
|
|
8558
|
-
const lines = [...this.activeSubagents.entries()].map(([
|
|
10076
|
+
const lines = [...this.activeSubagents.entries()].map(([, sub]) => {
|
|
8559
10077
|
const card = this.findSubagentRow(sub.id);
|
|
8560
|
-
const label = card
|
|
8561
|
-
const activity = card
|
|
10078
|
+
const label = card === undefined ? this.subagentNameFor(sub.id) : subagentDisplayName(card);
|
|
10079
|
+
const activity = card === undefined ? '' : (() => {
|
|
10080
|
+
const summary = subagentChipSummary(card);
|
|
10081
|
+
return summary === '' ? '' : ` · ${summary}`;
|
|
10082
|
+
})();
|
|
8562
10083
|
return t('sub.listLine', {
|
|
8563
10084
|
label,
|
|
10085
|
+
// `/subagents kill` takes this id, and the list is where a user
|
|
10086
|
+
// looks it up: the chip itself stays name-only.
|
|
8564
10087
|
id: sub.id,
|
|
8565
10088
|
provider: sub.provider,
|
|
8566
10089
|
seconds: Math.floor((Date.now() - sub.startedAt) / 1000),
|
|
8567
|
-
run: runId.slice(0, 8),
|
|
8568
10090
|
activity,
|
|
8569
10091
|
});
|
|
8570
10092
|
});
|