dsh-ssh-tui 0.7.1 → 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 +76 -7
- package/README.md +50 -7
- package/lib/approval-cache.js +12 -9
- package/lib/approval-cache.js.map +1 -1
- package/lib/color-depth.js +10 -4
- package/lib/color-depth.js.map +1 -1
- package/lib/diag.js +41 -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 +61 -41
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +61 -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 +1789 -275
- package/lib/tui.js.map +1 -1
- package/lib/types/color-depth.d.ts +3 -3
- 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/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
|
}
|
|
@@ -1461,7 +1625,7 @@ export class SshTui {
|
|
|
1461
1625
|
...(model === undefined ? {} : { model }),
|
|
1462
1626
|
routes: Object.keys(providers),
|
|
1463
1627
|
routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
|
|
1464
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
1628
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
1465
1629
|
...(sub.model === undefined ? {} : { subModel: sub.model }),
|
|
1466
1630
|
};
|
|
1467
1631
|
}
|
|
@@ -1602,7 +1766,7 @@ export class SshTui {
|
|
|
1602
1766
|
return true;
|
|
1603
1767
|
if (this.llmRetry !== undefined)
|
|
1604
1768
|
return true;
|
|
1605
|
-
if (this.
|
|
1769
|
+
if (this.compactionRunning())
|
|
1606
1770
|
return true;
|
|
1607
1771
|
return false;
|
|
1608
1772
|
}
|
|
@@ -1928,7 +2092,8 @@ export class SshTui {
|
|
|
1928
2092
|
this.paintRttMs = rttMs;
|
|
1929
2093
|
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
1930
2094
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
|
|
1931
|
-
this.
|
|
2095
|
+
if (!this.lineMode)
|
|
2096
|
+
this.startRenderTimer();
|
|
1932
2097
|
}
|
|
1933
2098
|
this.markDirty();
|
|
1934
2099
|
}
|
|
@@ -2042,7 +2207,7 @@ export class SshTui {
|
|
|
2042
2207
|
return false;
|
|
2043
2208
|
if (this.streaming?.text)
|
|
2044
2209
|
return false;
|
|
2045
|
-
if (this.
|
|
2210
|
+
if (this.compactionRunning())
|
|
2046
2211
|
return false;
|
|
2047
2212
|
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
2048
2213
|
return false;
|
|
@@ -2061,12 +2226,25 @@ export class SshTui {
|
|
|
2061
2226
|
...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
|
|
2062
2227
|
...(liveTool !== undefined || liveSub === undefined
|
|
2063
2228
|
? {}
|
|
2064
|
-
: { toolTitle: liveSub
|
|
2229
|
+
: { toolTitle: subagentDisplayName(liveSub), toolSummary: subagentChipSummary(liveSub) }),
|
|
2065
2230
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
2066
2231
|
};
|
|
2067
2232
|
}
|
|
2068
2233
|
findSubagentRow(sessionId) {
|
|
2069
|
-
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') });
|
|
2070
2248
|
}
|
|
2071
2249
|
findLivePlanRow() {
|
|
2072
2250
|
return findLivePlanRow(this.rows);
|
|
@@ -2254,7 +2432,7 @@ export class SshTui {
|
|
|
2254
2432
|
return;
|
|
2255
2433
|
const body = buffer
|
|
2256
2434
|
.map(piece => (piece.emphasis
|
|
2257
|
-
?
|
|
2435
|
+
? this.styleEmphasisedPiece(line.kind, piece.text)
|
|
2258
2436
|
: this.styleLine(line.kind, piece.text)))
|
|
2259
2437
|
.join('');
|
|
2260
2438
|
rows.push(` ${body}`);
|
|
@@ -2276,9 +2454,59 @@ export class SshTui {
|
|
|
2276
2454
|
flush();
|
|
2277
2455
|
return rows.length === 0 ? [this.styleLine(line.kind, '')] : rows;
|
|
2278
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
|
+
}
|
|
2279
2500
|
paintToolBodyLine(addDisplay, row, line, width) {
|
|
2280
2501
|
const inner = Math.max(1, width - 2);
|
|
2281
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
|
+
}
|
|
2282
2510
|
// An emphasised line is styled in parts: the escapes wrap *styled* pieces,
|
|
2283
2511
|
// because the painter's sanitiser strips any escape that reaches it inside
|
|
2284
2512
|
// the text — which is how a literal `[7m` once appeared on screen.
|
|
@@ -2312,7 +2540,7 @@ export class SshTui {
|
|
|
2312
2540
|
const dialog = this.dialog;
|
|
2313
2541
|
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
2314
2542
|
return;
|
|
2315
|
-
const header = this.styleLine('system', truncateToWidth(
|
|
2543
|
+
const header = this.styleLine('system', truncateToWidth(dialog.title, width));
|
|
2316
2544
|
const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
|
|
2317
2545
|
const divider = this.styleLine('system', repeatToWidth('─', width));
|
|
2318
2546
|
const bodyBudget = Math.max(1, height - 4);
|
|
@@ -2323,18 +2551,19 @@ export class SshTui {
|
|
|
2323
2551
|
for (const wrapped of wrap(line.text, inner)) {
|
|
2324
2552
|
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2325
2553
|
const kind = line.kind;
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
: kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
|
|
2329
|
-
|| kind === 'todo-failed' || kind === 'todo-skipped'
|
|
2330
|
-
? this.styleLine(kind, body)
|
|
2331
|
-
: kind === 'error'
|
|
2332
|
-
? this.styleLine('error', body)
|
|
2333
|
-
: kind === 'assistant'
|
|
2334
|
-
? this.styleLine('assistant', body)
|
|
2335
|
-
: this.styleLine('tool-result', body));
|
|
2554
|
+
const styled = this.styleLine(kind === 'subagent' ? 'subagent-header' : kind, body);
|
|
2555
|
+
rendered.push(this.markSearchRow(styled, wrapped));
|
|
2336
2556
|
}
|
|
2337
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
|
+
}
|
|
2338
2567
|
const maxOffset = Math.max(0, rendered.length - bodyBudget);
|
|
2339
2568
|
if (dialog.offset > maxOffset)
|
|
2340
2569
|
dialog.offset = maxOffset;
|
|
@@ -2369,15 +2598,67 @@ export class SshTui {
|
|
|
2369
2598
|
this.lastChromeStart = 0;
|
|
2370
2599
|
this.lastTranscriptStart = -1;
|
|
2371
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
|
+
}
|
|
2372
2611
|
openToolInspect(row) {
|
|
2373
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;
|
|
2374
2635
|
this.openDialog({
|
|
2375
2636
|
kind: 'inspect',
|
|
2376
|
-
title
|
|
2637
|
+
title,
|
|
2377
2638
|
lines,
|
|
2378
2639
|
offset: 0,
|
|
2640
|
+
subagentSessionId: this.subagentInspectId(row),
|
|
2379
2641
|
});
|
|
2380
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
|
+
}
|
|
2381
2662
|
closeInspect() {
|
|
2382
2663
|
if (this.dialog?.kind !== 'inspect')
|
|
2383
2664
|
return;
|
|
@@ -2395,12 +2676,12 @@ export class SshTui {
|
|
|
2395
2676
|
if (!row.expanded) {
|
|
2396
2677
|
const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
|
|
2397
2678
|
const styled = paint(collapsed);
|
|
2398
|
-
addDisplay(
|
|
2679
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2399
2680
|
return;
|
|
2400
2681
|
}
|
|
2401
2682
|
for (const wrapped of wrap(plain, width)) {
|
|
2402
2683
|
const styled = paint(wrapped);
|
|
2403
|
-
addDisplay(
|
|
2684
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2404
2685
|
}
|
|
2405
2686
|
}
|
|
2406
2687
|
/** Move the expand/collapse focus among reasoning and tool rows. */
|
|
@@ -2434,13 +2715,31 @@ export class SshTui {
|
|
|
2434
2715
|
return;
|
|
2435
2716
|
this.toggleCard(target);
|
|
2436
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
|
+
}
|
|
2437
2733
|
toggleCard(target) {
|
|
2438
|
-
if (target.kind === '
|
|
2734
|
+
if (target.kind === 'subagent') {
|
|
2735
|
+
this.focusedRow = target;
|
|
2736
|
+
this.openSubagentInspect(target);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
if (target.kind === 'tool') {
|
|
2439
2740
|
const width = Math.max(10, this.screenColumns());
|
|
2440
2741
|
const height = Math.max(6, this.screenRows());
|
|
2441
|
-
|
|
2442
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2443
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2742
|
+
if (this.toolBodyTruncated(target, width, height)) {
|
|
2444
2743
|
this.focusedRow = target;
|
|
2445
2744
|
this.openToolInspect(target);
|
|
2446
2745
|
return;
|
|
@@ -2467,6 +2766,8 @@ export class SshTui {
|
|
|
2467
2766
|
const height = Math.max(6, this.screenRows());
|
|
2468
2767
|
const workspace = this.workspaceRowsFor(width, height);
|
|
2469
2768
|
for (const row of rows) {
|
|
2769
|
+
if (row.kind === 'subagent')
|
|
2770
|
+
continue;
|
|
2470
2771
|
if (row.kind === 'tool') {
|
|
2471
2772
|
const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
|
|
2472
2773
|
if (!toolBodyFitsWorkspace(bodyRows, workspace))
|
|
@@ -2519,12 +2820,15 @@ export class SshTui {
|
|
|
2519
2820
|
revealRow(row) {
|
|
2520
2821
|
if (row === undefined)
|
|
2521
2822
|
return;
|
|
2823
|
+
if (row.kind === 'subagent') {
|
|
2824
|
+
this.focusedRow = row;
|
|
2825
|
+
this.openSubagentInspect(row);
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2522
2828
|
if (row.kind === 'tool') {
|
|
2523
2829
|
const width = Math.max(10, this.screenColumns());
|
|
2524
2830
|
const height = Math.max(6, this.screenRows());
|
|
2525
|
-
|
|
2526
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2527
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2831
|
+
if (this.toolBodyTruncated(row, width, height)) {
|
|
2528
2832
|
this.focusedRow = row;
|
|
2529
2833
|
this.openToolInspect(row);
|
|
2530
2834
|
return;
|
|
@@ -2696,7 +3000,7 @@ export class SshTui {
|
|
|
2696
3000
|
const header = t('reason.done', { marker, lines }) + (row.expanded ? '' : t('card.expand'));
|
|
2697
3001
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2698
3002
|
const styled = this.styleLine('reasoning', line);
|
|
2699
|
-
addDisplay(
|
|
3003
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2700
3004
|
if (row.expanded) {
|
|
2701
3005
|
for (const wrapped of wrap(row.text, width)) {
|
|
2702
3006
|
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
@@ -2727,13 +3031,13 @@ export class SshTui {
|
|
|
2727
3031
|
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
2728
3032
|
const styled = headerSegments.length === 0
|
|
2729
3033
|
? collapsed
|
|
2730
|
-
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
2731
|
-
addDisplay(
|
|
3034
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
3035
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2732
3036
|
continue;
|
|
2733
3037
|
}
|
|
2734
3038
|
const expandedHeaderLines = headerSegments.length === 0
|
|
2735
3039
|
? wrap(header.plain, width)
|
|
2736
|
-
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
3040
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
|
|
2737
3041
|
for (const wrapped of expandedHeaderLines) {
|
|
2738
3042
|
addDisplay(wrapped, row);
|
|
2739
3043
|
}
|
|
@@ -2752,46 +3056,24 @@ export class SshTui {
|
|
|
2752
3056
|
}
|
|
2753
3057
|
if (row.kind === 'subagent') {
|
|
2754
3058
|
const running = row.status === 'running';
|
|
2755
|
-
const
|
|
2756
|
-
const
|
|
2757
|
-
const
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
};
|
|
2767
|
-
const
|
|
2768
|
-
const
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
provider: row.provider,
|
|
2774
|
-
external: row.local ? '' : t('sub.external'),
|
|
2775
|
-
})), row);
|
|
2776
|
-
if (row.stopReason !== undefined) {
|
|
2777
|
-
addDisplay(this.styleLine('system', t('sub.stopReason', { reason: row.stopReason })), row);
|
|
2778
|
-
}
|
|
2779
|
-
if (row.logs.length === 0) {
|
|
2780
|
-
addDisplay(this.styleLine('system', running ? t('sub.cardWait') : t('sub.cardEmpty')), row);
|
|
2781
|
-
}
|
|
2782
|
-
else {
|
|
2783
|
-
for (const entry of row.logs) {
|
|
2784
|
-
const kind = entry.kind === 'assistant'
|
|
2785
|
-
? 'assistant'
|
|
2786
|
-
: entry.kind === 'result' && row.status === 'error'
|
|
2787
|
-
? 'error'
|
|
2788
|
-
: 'system';
|
|
2789
|
-
for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
|
|
2790
|
-
addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
|
|
2791
|
-
}
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
}
|
|
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);
|
|
2795
3077
|
continue;
|
|
2796
3078
|
}
|
|
2797
3079
|
if (row.kind === 'plan') {
|
|
@@ -2922,7 +3204,7 @@ export class SshTui {
|
|
|
2922
3204
|
+ (elapsed > 0 ? t('reason.elapsed', { seconds: elapsed }) : '');
|
|
2923
3205
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2924
3206
|
const styled = this.styleLine('reasoning', line);
|
|
2925
|
-
addDisplay(
|
|
3207
|
+
addDisplay(this.selectLine(styled, focused), block);
|
|
2926
3208
|
if (block.expanded) {
|
|
2927
3209
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
2928
3210
|
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
@@ -3041,8 +3323,50 @@ export class SshTui {
|
|
|
3041
3323
|
addDialog(t('onboard.ctrlF'));
|
|
3042
3324
|
if (ob.providerType === 'catalog')
|
|
3043
3325
|
addDialog(t('onboard.modelsCatalogHint'));
|
|
3326
|
+
addDialog(t('onboard.modelsPickHint'));
|
|
3044
3327
|
addDialog(t('onboard.enterEsc'));
|
|
3045
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
|
+
}
|
|
3046
3370
|
case 'context':
|
|
3047
3371
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3048
3372
|
addDialog(t('onboard.contextPrompt'));
|
|
@@ -3061,6 +3385,9 @@ export class SshTui {
|
|
|
3061
3385
|
api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
|
|
3062
3386
|
}));
|
|
3063
3387
|
addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
|
|
3388
|
+
if (ob.defaultModel !== undefined) {
|
|
3389
|
+
addDialog(t('onboard.confirmDefaultModel', { model: ob.defaultModel }));
|
|
3390
|
+
}
|
|
3064
3391
|
if (ob.routeContextWindow !== undefined) {
|
|
3065
3392
|
addDialog(t('onboard.confirmContext', { value: String(ob.routeContextWindow) }));
|
|
3066
3393
|
}
|
|
@@ -3158,8 +3485,8 @@ export class SshTui {
|
|
|
3158
3485
|
continue;
|
|
3159
3486
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
3160
3487
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
3161
|
-
suggestionLines.push(index === this.suggestionIndex
|
|
3162
|
-
?
|
|
3488
|
+
suggestionLines.push(index === this.suggestionIndex
|
|
3489
|
+
? this.selectLine(fitLine(line), true)
|
|
3163
3490
|
: this.styleLine('system', fitLine(line)));
|
|
3164
3491
|
}
|
|
3165
3492
|
if (suggestionEnd < this.commandSuggestions.length) {
|
|
@@ -3265,7 +3592,7 @@ export class SshTui {
|
|
|
3265
3592
|
? formatFooterBalance(this.balanceSnapshot)
|
|
3266
3593
|
: undefined;
|
|
3267
3594
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3268
|
-
const compacting = this.
|
|
3595
|
+
const compacting = this.compactionRunning();
|
|
3269
3596
|
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3270
3597
|
const sub = this.subagentSelection.current;
|
|
3271
3598
|
const footer = {
|
|
@@ -3289,7 +3616,7 @@ export class SshTui {
|
|
|
3289
3616
|
provider,
|
|
3290
3617
|
parentModel,
|
|
3291
3618
|
subModel: sub.model,
|
|
3292
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
3619
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
3293
3620
|
...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
|
|
3294
3621
|
// Quota and context pressure stay on the identity line, where they have
|
|
3295
3622
|
// always been: B-1 moved them one row up into the strip, and a user
|
|
@@ -3326,11 +3653,18 @@ export class SshTui {
|
|
|
3326
3653
|
: activity.text;
|
|
3327
3654
|
const identity = footerIdentityParts(footer);
|
|
3328
3655
|
const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
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
|
+
}
|
|
3334
3668
|
// The strip: one loss order for the groups a narrow terminal can do without.
|
|
3335
3669
|
// Health leads because it is the only group that reports a broken install;
|
|
3336
3670
|
// the session counters go before the operational signals. Quota and context
|
|
@@ -3480,7 +3814,7 @@ export class SshTui {
|
|
|
3480
3814
|
}
|
|
3481
3815
|
currentSelectionLabel() {
|
|
3482
3816
|
const current = this.selectionRef?.current;
|
|
3483
|
-
const provider = this.currentProviderId();
|
|
3817
|
+
const provider = this.displayProviderId(this.currentProviderId());
|
|
3484
3818
|
const model = current?.model ?? this.agent.options.model ?? 'unknown';
|
|
3485
3819
|
const effort = current?.reasoningEffort;
|
|
3486
3820
|
const kind = describeProviderRoute(provider).short;
|
|
@@ -3492,7 +3826,7 @@ export class SshTui {
|
|
|
3492
3826
|
}
|
|
3493
3827
|
/** Refresh the terminal window title (throttled while running). */
|
|
3494
3828
|
updateTerminalTitle() {
|
|
3495
|
-
if (this.exiting)
|
|
3829
|
+
if (this.exiting || this.lineMode)
|
|
3496
3830
|
return;
|
|
3497
3831
|
const now = Date.now();
|
|
3498
3832
|
// Completion wins over a still-running agent status: the turn/end event
|
|
@@ -3503,16 +3837,20 @@ export class SshTui {
|
|
|
3503
3837
|
this.write(t('title.done', { suffix: titleSuffix }));
|
|
3504
3838
|
return;
|
|
3505
3839
|
}
|
|
3506
|
-
|
|
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) {
|
|
3507
3845
|
if (now - this.lastTitleUpdateAt < 800)
|
|
3508
3846
|
return;
|
|
3509
3847
|
this.lastTitleUpdateAt = now;
|
|
3510
3848
|
const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
|
|
3511
|
-
let detail = t('title.running');
|
|
3849
|
+
let detail = compacting ? t('title.compacting') : t('title.running');
|
|
3512
3850
|
if (this.dialog?.kind === 'questions') {
|
|
3513
3851
|
detail = planReviewOf(this.dialog.question) ? t('question.planTitle') : t('title.waitAnswer');
|
|
3514
3852
|
}
|
|
3515
|
-
else if (
|
|
3853
|
+
else if (compacting) {
|
|
3516
3854
|
detail = t('title.compacting');
|
|
3517
3855
|
}
|
|
3518
3856
|
else if (this.activeSubagents.size > 0) {
|
|
@@ -3538,6 +3876,8 @@ export class SshTui {
|
|
|
3538
3876
|
}
|
|
3539
3877
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3540
3878
|
playCompletionSignal() {
|
|
3879
|
+
if (this.lineMode)
|
|
3880
|
+
return;
|
|
3541
3881
|
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
3542
3882
|
if (disabled)
|
|
3543
3883
|
return;
|
|
@@ -3999,15 +4339,50 @@ export class SshTui {
|
|
|
3999
4339
|
kind === 'todo-skipped' ? '2;33' :
|
|
4000
4340
|
kind === 'todo-pending' ? '90' :
|
|
4001
4341
|
kind === 'plan-dock' ? '38;5;180' :
|
|
4002
|
-
kind === '
|
|
4003
|
-
kind === '
|
|
4004
|
-
'
|
|
4342
|
+
kind === 'subagent-header' ? '38;5;141' :
|
|
4343
|
+
kind === 'diag' ? '2;36' :
|
|
4344
|
+
kind === 'error' ? '31' :
|
|
4345
|
+
'90';
|
|
4005
4346
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4006
4347
|
// Nothing left to paint: the text stands on its own.
|
|
4007
4348
|
if (code === '')
|
|
4008
4349
|
return safe;
|
|
4009
4350
|
return `\x1b[${code}m${safe}\x1b[0m`;
|
|
4010
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
|
+
}
|
|
4011
4386
|
/**
|
|
4012
4387
|
* Fold one live or durable stream chunk into the in-progress assistant
|
|
4013
4388
|
* row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
|
|
@@ -4080,6 +4455,12 @@ export class SshTui {
|
|
|
4080
4455
|
* The parent request is left untouched (its own `/model` waterfall already
|
|
4081
4456
|
* owns the route); direct children created by tool-subagent inherit the
|
|
4082
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.
|
|
4083
4464
|
*/
|
|
4084
4465
|
handleAgentRequest = async ({ agent }, next) => {
|
|
4085
4466
|
const resolved = await next();
|
|
@@ -4088,14 +4469,23 @@ export class SshTui {
|
|
|
4088
4469
|
const selection = this.subagentSelection.current;
|
|
4089
4470
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
4090
4471
|
const provider = selection.provider ?? parentProvider;
|
|
4091
|
-
|
|
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)
|
|
4092
4476
|
? selection.model
|
|
4093
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);
|
|
4094
4484
|
return {
|
|
4095
|
-
...
|
|
4485
|
+
...withoutParentEffort,
|
|
4096
4486
|
provider,
|
|
4097
4487
|
model,
|
|
4098
|
-
...(
|
|
4488
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
4099
4489
|
};
|
|
4100
4490
|
};
|
|
4101
4491
|
handleSessionEvent = (session, event) => {
|
|
@@ -4225,6 +4615,30 @@ export class SshTui {
|
|
|
4225
4615
|
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
4226
4616
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
4227
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
|
+
}
|
|
4228
4642
|
const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
|
|
4229
4643
|
if (previous !== undefined) {
|
|
4230
4644
|
this.mergeIntoToolCard(previous, {
|
|
@@ -4267,8 +4681,30 @@ export class SshTui {
|
|
|
4267
4681
|
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
4268
4682
|
this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
|
|
4269
4683
|
const callId = String(event.data.message.source.callId);
|
|
4270
|
-
|
|
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));
|
|
4271
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);
|
|
4272
4708
|
if (row !== undefined) {
|
|
4273
4709
|
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
4274
4710
|
if (metaDiffs !== null) {
|
|
@@ -4303,11 +4739,14 @@ export class SshTui {
|
|
|
4303
4739
|
?? (typeof sourceName === 'string' ? sourceName : '');
|
|
4304
4740
|
if (recordedName !== '' && HIDDEN_TOOL_NAMES.has(recordedName))
|
|
4305
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;
|
|
4306
4747
|
// Never title a card with the call id (`call-<uuid>`). Prefer the
|
|
4307
4748
|
// recorded tool name; fall back to a generic tool card.
|
|
4308
|
-
const toolName = recordedName
|
|
4309
|
-
? 'tool'
|
|
4310
|
-
: recordedName;
|
|
4749
|
+
const toolName = displayToolName(recordedName);
|
|
4311
4750
|
const present = presentToolCall(toolName, '');
|
|
4312
4751
|
this.pushRow({
|
|
4313
4752
|
kind: 'tool',
|
|
@@ -4356,6 +4795,9 @@ export class SshTui {
|
|
|
4356
4795
|
this.stalledWarningShown = false;
|
|
4357
4796
|
this.llmRetry = undefined;
|
|
4358
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();
|
|
4359
4801
|
this.markDirty();
|
|
4360
4802
|
break;
|
|
4361
4803
|
case 'turn/end': {
|
|
@@ -4364,6 +4806,10 @@ export class SshTui {
|
|
|
4364
4806
|
this.toolCallNames.clear();
|
|
4365
4807
|
this.statsTracker.noteTurnEnd();
|
|
4366
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;
|
|
4367
4813
|
this.pendingMessages.clear();
|
|
4368
4814
|
// Aborted/errored turns may close without an assembled
|
|
4369
4815
|
// assistant/message; never leave a half-streamed thinking block behind.
|
|
@@ -4538,6 +4984,12 @@ export class SshTui {
|
|
|
4538
4984
|
if (this.llmRetry !== undefined) {
|
|
4539
4985
|
this.pushRow({ kind: 'system', text: t('retry.started', { retry: this.llmRetry.retry }) });
|
|
4540
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;
|
|
4541
4993
|
this.markDirty();
|
|
4542
4994
|
return;
|
|
4543
4995
|
}
|
|
@@ -4560,7 +5012,54 @@ export class SshTui {
|
|
|
4560
5012
|
if (named !== undefined)
|
|
4561
5013
|
return named;
|
|
4562
5014
|
}
|
|
4563
|
-
|
|
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();
|
|
4564
5063
|
}
|
|
4565
5064
|
handleCompactionEvent(type, event) {
|
|
4566
5065
|
const payload = event.data;
|
|
@@ -4671,11 +5170,14 @@ export class SshTui {
|
|
|
4671
5170
|
canRunCompactCommand() {
|
|
4672
5171
|
if (this.replaying || this.agentGone || this.exiting)
|
|
4673
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 });
|
|
4674
5176
|
if (this.agent.status === 'running')
|
|
4675
5177
|
return false;
|
|
4676
5178
|
if (this.idleCompactInFlight)
|
|
4677
5179
|
return false;
|
|
4678
|
-
if (this.
|
|
5180
|
+
if (this.compactionRunning())
|
|
4679
5181
|
return false;
|
|
4680
5182
|
return this.ctx.get('commands') !== undefined;
|
|
4681
5183
|
}
|
|
@@ -4695,8 +5197,13 @@ export class SshTui {
|
|
|
4695
5197
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4696
5198
|
return;
|
|
4697
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 });
|
|
4698
5205
|
if (this.agent.status === 'running'
|
|
4699
|
-
|| this.
|
|
5206
|
+
|| this.compactionRunning()) {
|
|
4700
5207
|
if (reason === 'user')
|
|
4701
5208
|
this.pushRow({ kind: 'error', text: t('compact.busy') });
|
|
4702
5209
|
this.markDirty();
|
|
@@ -4727,7 +5234,7 @@ export class SshTui {
|
|
|
4727
5234
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4728
5235
|
return;
|
|
4729
5236
|
}
|
|
4730
|
-
const compactionRunning = () => this.
|
|
5237
|
+
const compactionRunning = () => this.compactionRunning();
|
|
4731
5238
|
const releaseIfSettled = () => {
|
|
4732
5239
|
queueMicrotask(() => {
|
|
4733
5240
|
if (!compactionRunning())
|
|
@@ -4828,7 +5335,7 @@ export class SshTui {
|
|
|
4828
5335
|
if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
|
|
4829
5336
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
4830
5337
|
}
|
|
4831
|
-
if (!this.
|
|
5338
|
+
if (!this.compactionRunning()) {
|
|
4832
5339
|
this.idleCompactInFlight = false;
|
|
4833
5340
|
}
|
|
4834
5341
|
this.markDirty();
|
|
@@ -4916,35 +5423,72 @@ export class SshTui {
|
|
|
4916
5423
|
switch (event.type) {
|
|
4917
5424
|
case 'user/message': {
|
|
4918
5425
|
const text = collectText(event.data.content);
|
|
4919
|
-
|
|
4920
|
-
|
|
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);
|
|
4921
5430
|
break;
|
|
4922
5431
|
}
|
|
4923
5432
|
case 'assistant/message': {
|
|
4924
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.
|
|
4925
5436
|
if (text !== '')
|
|
4926
|
-
appendSubagentLog(row, { kind: 'assistant', text:
|
|
5437
|
+
appendSubagentLog(row, { kind: 'assistant', text: clipSubagentActivity(text, 200) });
|
|
4927
5438
|
break;
|
|
4928
5439
|
}
|
|
4929
5440
|
case 'tool/call': {
|
|
4930
5441
|
if (HIDDEN_TOOL_NAMES.has(event.data.name))
|
|
4931
5442
|
break;
|
|
5443
|
+
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
4932
5444
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4933
|
-
appendSubagentLog(row, {
|
|
5445
|
+
appendSubagentLog(row, {
|
|
5446
|
+
kind: 'tool',
|
|
5447
|
+
text: `▶ ${present.title} ${clipSubagentActivity(present.summary, 48)}`.trimEnd(),
|
|
5448
|
+
callId: String(event.data.callId),
|
|
5449
|
+
});
|
|
4934
5450
|
break;
|
|
4935
5451
|
}
|
|
4936
5452
|
case 'tool/result': {
|
|
4937
|
-
const output = truncate(collectText(event.data.message.content), 3);
|
|
4938
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();
|
|
4939
5462
|
appendSubagentLog(row, {
|
|
4940
5463
|
kind: 'result',
|
|
4941
|
-
text: `${ok ? '✓' : '✗'} ${
|
|
5464
|
+
text: `${ok ? '✓' : '✗'} ${title}`,
|
|
5465
|
+
...(output === '' ? {} : { detail: output }),
|
|
5466
|
+
callId,
|
|
4942
5467
|
});
|
|
4943
5468
|
break;
|
|
4944
5469
|
}
|
|
4945
|
-
case 'turn/end':
|
|
4946
|
-
|
|
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
|
+
});
|
|
4947
5490
|
break;
|
|
5491
|
+
}
|
|
4948
5492
|
case 'approval/asked':
|
|
4949
5493
|
appendSubagentLog(row, { kind: 'approval', text: t('sub.approval', { tool: event.data.toolName }) });
|
|
4950
5494
|
break;
|
|
@@ -4953,6 +5497,7 @@ export class SshTui {
|
|
|
4953
5497
|
break;
|
|
4954
5498
|
}
|
|
4955
5499
|
this.lastActivity = Date.now();
|
|
5500
|
+
this.refreshOpenSubagentInspect(row);
|
|
4956
5501
|
this.markDirty();
|
|
4957
5502
|
};
|
|
4958
5503
|
handleSubagentStart = (info) => {
|
|
@@ -4964,31 +5509,53 @@ export class SshTui {
|
|
|
4964
5509
|
});
|
|
4965
5510
|
this.subagentSessions.add(sessionId);
|
|
4966
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.
|
|
4967
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
|
+
});
|
|
4968
5523
|
if (existing !== undefined) {
|
|
5524
|
+
existing.childSessionId = sessionId;
|
|
4969
5525
|
existing.runId = String(info.runId);
|
|
4970
5526
|
existing.provider = info.provider;
|
|
5527
|
+
existing.modelProvider = modelProvider;
|
|
4971
5528
|
existing.local = info.local;
|
|
4972
5529
|
existing.status = 'running';
|
|
4973
5530
|
existing.startedAt = Date.now();
|
|
4974
5531
|
existing.endedAt = undefined;
|
|
4975
5532
|
existing.stopReason = undefined;
|
|
5533
|
+
delete existing.failHint;
|
|
4976
5534
|
existing.lastActivity = t('sub.started');
|
|
4977
5535
|
existing.expanded = false;
|
|
4978
|
-
|
|
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);
|
|
4979
5542
|
}
|
|
4980
5543
|
else {
|
|
4981
5544
|
this.pushRow({
|
|
4982
5545
|
kind: 'subagent',
|
|
4983
5546
|
sessionId,
|
|
5547
|
+
childSessionId: sessionId,
|
|
5548
|
+
...(pending === undefined ? {} : { spawnCallId: pending.callId }),
|
|
4984
5549
|
runId: String(info.runId),
|
|
4985
5550
|
provider: info.provider,
|
|
5551
|
+
modelProvider,
|
|
4986
5552
|
local: info.local,
|
|
4987
5553
|
label: t('sub.label', { provider: info.provider }),
|
|
5554
|
+
...(task === undefined ? {} : { task }),
|
|
4988
5555
|
status: 'running',
|
|
4989
5556
|
startedAt: Date.now(),
|
|
4990
5557
|
lastActivity: t('sub.started'),
|
|
4991
|
-
logs: [{ kind: 'system', text:
|
|
5558
|
+
logs: [{ kind: 'system', text: startedText }],
|
|
4992
5559
|
expanded: false,
|
|
4993
5560
|
});
|
|
4994
5561
|
}
|
|
@@ -5000,37 +5567,79 @@ export class SshTui {
|
|
|
5000
5567
|
this.lastActivity = Date.now();
|
|
5001
5568
|
const output = info.lastAssistantMessage === undefined
|
|
5002
5569
|
? ''
|
|
5003
|
-
:
|
|
5004
|
-
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);
|
|
5005
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;
|
|
5006
5595
|
if (row !== undefined) {
|
|
5007
5596
|
row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
|
|
5008
5597
|
row.endedAt = Date.now();
|
|
5009
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;
|
|
5010
5605
|
appendSubagentLog(row, {
|
|
5011
5606
|
kind: failed ? 'result' : 'assistant',
|
|
5012
|
-
text:
|
|
5607
|
+
text: endText,
|
|
5013
5608
|
});
|
|
5609
|
+
this.refreshOpenSubagentInspect(row);
|
|
5014
5610
|
}
|
|
5015
5611
|
else {
|
|
5016
5612
|
this.pushRow({
|
|
5017
5613
|
kind: 'subagent',
|
|
5018
5614
|
sessionId: String(info.id),
|
|
5615
|
+
childSessionId: String(info.id),
|
|
5019
5616
|
runId: String(info.runId),
|
|
5020
5617
|
provider: info.provider,
|
|
5618
|
+
modelProvider,
|
|
5021
5619
|
local: info.local,
|
|
5022
5620
|
label: t('sub.label', { provider: info.provider }),
|
|
5023
5621
|
status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
|
|
5024
5622
|
startedAt: Date.now(),
|
|
5025
5623
|
endedAt: Date.now(),
|
|
5026
5624
|
stopReason: info.stopReason,
|
|
5027
|
-
|
|
5028
|
-
|
|
5625
|
+
...(explained === undefined ? {} : { failHint: explained.hint }),
|
|
5626
|
+
lastActivity: endText,
|
|
5627
|
+
logs: [{ kind: failed ? 'result' : 'system', text: endText }],
|
|
5029
5628
|
expanded: false,
|
|
5030
5629
|
});
|
|
5031
5630
|
}
|
|
5032
5631
|
this.markDirty();
|
|
5033
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
|
+
}
|
|
5034
5643
|
// ── approval and questions ──────────────────────────────────────────────
|
|
5035
5644
|
hasLiveDisplay() {
|
|
5036
5645
|
if (this.disposed || this.exiting)
|
|
@@ -5120,7 +5729,7 @@ export class SshTui {
|
|
|
5120
5729
|
const selection = this.subagentSelection.current;
|
|
5121
5730
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5122
5731
|
const provider = selection.provider ?? parentProvider;
|
|
5123
|
-
const model = subagentModelMatchesProvider(provider, selection.model)
|
|
5732
|
+
const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
|
|
5124
5733
|
? selection.model
|
|
5125
5734
|
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
5126
5735
|
return { provider, model };
|
|
@@ -5346,9 +5955,7 @@ export class SshTui {
|
|
|
5346
5955
|
return 'cancelled';
|
|
5347
5956
|
}
|
|
5348
5957
|
}
|
|
5349
|
-
const agentLabel =
|
|
5350
|
-
? t('approval.thisSession')
|
|
5351
|
-
: t('sub.agentLabel', { id: request.agent.id });
|
|
5958
|
+
const agentLabel = t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5352
5959
|
return new Promise((resolve) => {
|
|
5353
5960
|
if (request.signal?.aborted === true) {
|
|
5354
5961
|
resolve('cancelled');
|
|
@@ -5386,7 +5993,7 @@ export class SshTui {
|
|
|
5386
5993
|
const answers = [];
|
|
5387
5994
|
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
5388
5995
|
? undefined
|
|
5389
|
-
: t('sub.agentLabel', {
|
|
5996
|
+
: t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5390
5997
|
const cards = [];
|
|
5391
5998
|
for (const question of request.questions) {
|
|
5392
5999
|
const card = {
|
|
@@ -5463,6 +6070,7 @@ export class SshTui {
|
|
|
5463
6070
|
openDialog(dialog) {
|
|
5464
6071
|
if (this.dialog === undefined) {
|
|
5465
6072
|
this.dialog = dialog;
|
|
6073
|
+
this.echoLineModeDialog(dialog);
|
|
5466
6074
|
}
|
|
5467
6075
|
else {
|
|
5468
6076
|
this.dialogQueue.push(dialog);
|
|
@@ -5475,9 +6083,53 @@ export class SshTui {
|
|
|
5475
6083
|
const next = this.dialogQueue.shift();
|
|
5476
6084
|
if (next !== undefined) {
|
|
5477
6085
|
this.dialog = next;
|
|
6086
|
+
this.echoLineModeDialog(next);
|
|
5478
6087
|
this.markDirty();
|
|
5479
6088
|
}
|
|
5480
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
|
+
}
|
|
5481
6133
|
removeQueuedDialog(dialog) {
|
|
5482
6134
|
const index = this.dialogQueue.indexOf(dialog);
|
|
5483
6135
|
if (index !== -1)
|
|
@@ -5560,43 +6212,281 @@ export class SshTui {
|
|
|
5560
6212
|
const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
|
|
5561
6213
|
return section?.providers?.[provider];
|
|
5562
6214
|
}
|
|
5563
|
-
/** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
|
|
5564
|
-
openCodeListingBaseURL(provider) {
|
|
5565
|
-
if (provider === 'opencode-go')
|
|
5566
|
-
return providerTemplates()['opencode-go'].defaultBaseUrl;
|
|
5567
|
-
if (provider === 'opencode')
|
|
5568
|
-
return OPENCODE_ZEN_BASE_URL;
|
|
5569
|
-
return undefined;
|
|
5570
|
-
}
|
|
5571
6215
|
/**
|
|
5572
|
-
*
|
|
5573
|
-
*
|
|
5574
|
-
*
|
|
5575
|
-
*
|
|
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.
|
|
5576
6232
|
*/
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
const profile = this.piAiProviderProfile(provider);
|
|
5580
|
-
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5581
|
-
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
5582
|
-
? profile.baseURL.trim()
|
|
5583
|
-
: this.openCodeListingBaseURL(provider);
|
|
5584
|
-
if (baseURL === undefined)
|
|
5585
|
-
return [];
|
|
5586
|
-
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
5587
|
-
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
5588
|
-
? profile.apiKeyEnv.trim()
|
|
5589
|
-
: source?.apiKeyEnv;
|
|
5590
|
-
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
5591
|
-
const llm = this.ctx.get('llm');
|
|
5592
|
-
if (llm === undefined)
|
|
6233
|
+
providerFamilyRows(provider) {
|
|
6234
|
+
if (provider === '')
|
|
5593
6235
|
return [];
|
|
5594
|
-
const
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
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))
|
|
6290
|
+
return [];
|
|
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;
|
|
5600
6490
|
}
|
|
5601
6491
|
/** Add one endpoint-listed model to the stored provider profile when needed. */
|
|
5602
6492
|
async ensureProviderModelConfigured(provider, modelId) {
|
|
@@ -5620,6 +6510,39 @@ export class SshTui {
|
|
|
5620
6510
|
}
|
|
5621
6511
|
if (ids.has(modelId))
|
|
5622
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
|
+
}
|
|
5623
6546
|
const modelEntry = { id: modelId };
|
|
5624
6547
|
// A model added here skips the wizard, so size it from the installed
|
|
5625
6548
|
// catalog the same way `/setup` does; the route default covers a miss.
|
|
@@ -5629,14 +6552,18 @@ export class SshTui {
|
|
|
5629
6552
|
if (contextWindow !== undefined)
|
|
5630
6553
|
modelEntry.contextWindow = contextWindow;
|
|
5631
6554
|
}
|
|
5632
|
-
|
|
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);
|
|
5633
6560
|
if (reasoningEfforts !== undefined)
|
|
5634
6561
|
modelEntry.reasoningEfforts = reasoningEfforts;
|
|
5635
6562
|
try {
|
|
5636
6563
|
await settings.mutate(settingsNamespace('llm-pi-ai'), [
|
|
5637
6564
|
{ op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
|
|
5638
6565
|
]);
|
|
5639
|
-
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) }) });
|
|
5640
6567
|
this.markDirty();
|
|
5641
6568
|
return true;
|
|
5642
6569
|
}
|
|
@@ -5681,16 +6608,22 @@ export class SshTui {
|
|
|
5681
6608
|
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
5682
6609
|
listSelectableProviders() {
|
|
5683
6610
|
const llm = this.ctx.get('llm');
|
|
5684
|
-
const current = this.currentProviderId();
|
|
6611
|
+
const current = this.displayProviderId(this.currentProviderId());
|
|
5685
6612
|
const seen = new Set();
|
|
5686
6613
|
const out = [];
|
|
5687
6614
|
const add = (id, name) => {
|
|
5688
|
-
if (id === ''
|
|
6615
|
+
if (id === '')
|
|
5689
6616
|
return;
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
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}` });
|
|
5694
6627
|
};
|
|
5695
6628
|
add(current);
|
|
5696
6629
|
for (const info of llm?.listProviders() ?? [])
|
|
@@ -5708,6 +6641,9 @@ export class SshTui {
|
|
|
5708
6641
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
5709
6642
|
];
|
|
5710
6643
|
async loadModelOptions(provider) {
|
|
6644
|
+
const family = this.providerFamilyRows(provider);
|
|
6645
|
+
if (family.length > 1)
|
|
6646
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
5711
6647
|
const llm = this.ctx.get('llm');
|
|
5712
6648
|
let options = [];
|
|
5713
6649
|
let source = t('model.configured');
|
|
@@ -5762,16 +6698,86 @@ export class SshTui {
|
|
|
5762
6698
|
}
|
|
5763
6699
|
return { options, source };
|
|
5764
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
|
+
}
|
|
5765
6768
|
/** /model: models and effort for the current provider only. */
|
|
5766
6769
|
async runModelCommand() {
|
|
5767
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);
|
|
5768
6774
|
const current = this.selectionRef?.current;
|
|
5769
6775
|
const loaded = await this.loadModelOptions(provider);
|
|
5770
6776
|
let modelOptions = loaded.options;
|
|
5771
6777
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
5772
6778
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
5773
6779
|
}
|
|
5774
|
-
const selected = await this.pickModelOption(modelOptions,
|
|
6780
|
+
const selected = await this.pickModelOption(modelOptions, display, loaded.source, current?.model);
|
|
5775
6781
|
if (selected === undefined)
|
|
5776
6782
|
return;
|
|
5777
6783
|
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
@@ -5779,7 +6785,10 @@ export class SshTui {
|
|
|
5779
6785
|
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
5780
6786
|
async runProviderCommand() {
|
|
5781
6787
|
const providers = this.listSelectableProviders();
|
|
5782
|
-
|
|
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());
|
|
5783
6792
|
if (providers.length === 0) {
|
|
5784
6793
|
this.pushRow({ kind: 'error', text: t('provider.none') });
|
|
5785
6794
|
this.markDirty();
|
|
@@ -5818,7 +6827,46 @@ export class SshTui {
|
|
|
5818
6827
|
: {};
|
|
5819
6828
|
return rememberedRouteFor(memory, provider);
|
|
5820
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
|
+
}
|
|
5821
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();
|
|
5822
6870
|
const settings = this.ctx.get('settings');
|
|
5823
6871
|
if (settings === undefined)
|
|
5824
6872
|
return;
|
|
@@ -5835,19 +6883,25 @@ export class SshTui {
|
|
|
5835
6883
|
]);
|
|
5836
6884
|
}
|
|
5837
6885
|
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
5838
|
-
|
|
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)))
|
|
5839
6893
|
return;
|
|
5840
6894
|
const llm = this.ctx.get('llm');
|
|
5841
6895
|
const current = this.selectionRef?.current;
|
|
5842
6896
|
let effortOptions = [];
|
|
5843
6897
|
try {
|
|
5844
|
-
const info = await llm?.resolveModelInfo(
|
|
6898
|
+
const info = await llm?.resolveModelInfo(target, modelId);
|
|
5845
6899
|
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
5846
6900
|
}
|
|
5847
6901
|
catch {
|
|
5848
6902
|
effortOptions = [];
|
|
5849
6903
|
}
|
|
5850
|
-
if (effortOptions.length === 0 && providerUsesLocalOAuth(
|
|
6904
|
+
if (effortOptions.length === 0 && providerUsesLocalOAuth(target)) {
|
|
5851
6905
|
effortOptions = localOAuthEffortChoices(modelId);
|
|
5852
6906
|
}
|
|
5853
6907
|
const isUndeclared = effortOptions.length === 0;
|
|
@@ -5864,8 +6918,8 @@ export class SshTui {
|
|
|
5864
6918
|
desc: undefined,
|
|
5865
6919
|
})),
|
|
5866
6920
|
];
|
|
5867
|
-
const rememberedEffort = this.rememberedRoute(
|
|
5868
|
-
const currentEffort = current?.provider ===
|
|
6921
|
+
const rememberedEffort = this.rememberedRoute(display)?.reasoningEffort ?? preferredEffort ?? '';
|
|
6922
|
+
const currentEffort = current?.provider === target
|
|
5869
6923
|
? String(current?.reasoningEffort ?? '')
|
|
5870
6924
|
: rememberedEffort;
|
|
5871
6925
|
const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
|
|
@@ -5881,9 +6935,9 @@ export class SshTui {
|
|
|
5881
6935
|
}, 0, 1, currentIndex);
|
|
5882
6936
|
const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
5883
6937
|
if (isUndeclared && effort !== undefined)
|
|
5884
|
-
await this.declareReasoningEffort(
|
|
6938
|
+
await this.declareReasoningEffort(target, modelId, effort);
|
|
5885
6939
|
const next = {
|
|
5886
|
-
provider,
|
|
6940
|
+
provider: target,
|
|
5887
6941
|
model: modelId,
|
|
5888
6942
|
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
5889
6943
|
};
|
|
@@ -5891,23 +6945,23 @@ export class SshTui {
|
|
|
5891
6945
|
this.selectionRef.current = next;
|
|
5892
6946
|
this.onSelectionChanged?.(next);
|
|
5893
6947
|
await this.persistDefaultSelection(next);
|
|
5894
|
-
await this.rememberRoute(next);
|
|
5895
|
-
const kind = describeProviderRoute(
|
|
6948
|
+
await this.rememberRoute({ ...next, provider: display });
|
|
6949
|
+
const kind = describeProviderRoute(display);
|
|
5896
6950
|
const effortText = effort ?? t('effort.defaultShort');
|
|
5897
6951
|
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
5898
6952
|
this.pushRow({
|
|
5899
6953
|
kind: 'system',
|
|
5900
|
-
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 }),
|
|
5901
6955
|
});
|
|
5902
6956
|
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5903
6957
|
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5904
|
-
if (previousProvider !==
|
|
5905
|
-
await this.syncSubagentToProvider(
|
|
5906
|
-
this.clearQuotaForProvider(
|
|
6958
|
+
if (this.displayProviderId(previousProvider) !== display) {
|
|
6959
|
+
await this.syncSubagentToProvider(display, listedIds, true);
|
|
6960
|
+
this.clearQuotaForProvider(target);
|
|
5907
6961
|
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5908
6962
|
}
|
|
5909
6963
|
else {
|
|
5910
|
-
await this.syncSubagentToProvider(
|
|
6964
|
+
await this.syncSubagentToProvider(display, listedIds);
|
|
5911
6965
|
}
|
|
5912
6966
|
this.markDirty();
|
|
5913
6967
|
}
|
|
@@ -5963,11 +7017,23 @@ export class SshTui {
|
|
|
5963
7017
|
* When the parent provider changes (OAuth or API key), keep the subagent
|
|
5964
7018
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
5965
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.
|
|
5966
7025
|
*/
|
|
5967
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);
|
|
5968
7030
|
const current = this.subagentSelection.current;
|
|
5969
|
-
if (
|
|
5970
|
-
|
|
7031
|
+
if (current.provider !== undefined) {
|
|
7032
|
+
if (!force)
|
|
7033
|
+
return;
|
|
7034
|
+
if (await this.confirmKeepSubagentPin(provider, current))
|
|
7035
|
+
return;
|
|
7036
|
+
}
|
|
5971
7037
|
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
5972
7038
|
return;
|
|
5973
7039
|
let catalog = [...listed];
|
|
@@ -5993,6 +7059,31 @@ export class SshTui {
|
|
|
5993
7059
|
text: t('sub.followed', { provider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
|
|
5994
7060
|
});
|
|
5995
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
|
+
}
|
|
5996
7087
|
clearQuotaForProvider(provider) {
|
|
5997
7088
|
const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
|
|
5998
7089
|
const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
|
|
@@ -6010,6 +7101,13 @@ export class SshTui {
|
|
|
6010
7101
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
6011
7102
|
async saveSubagentSelection(next) {
|
|
6012
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();
|
|
6013
7111
|
const settings = this.ctx.get('settings');
|
|
6014
7112
|
if (settings === undefined) {
|
|
6015
7113
|
this.pushRow({ kind: 'error', text: t('sub.settingsMissing') });
|
|
@@ -6021,6 +7119,9 @@ export class SshTui {
|
|
|
6021
7119
|
}
|
|
6022
7120
|
/** Resolve the picker model list for one provider (endpoint first, then catalog). */
|
|
6023
7121
|
async subagentModelOptions(provider) {
|
|
7122
|
+
const family = this.providerFamilyRows(provider);
|
|
7123
|
+
if (family.length > 1)
|
|
7124
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
6024
7125
|
const llm = this.ctx.get('llm');
|
|
6025
7126
|
let options = [];
|
|
6026
7127
|
let source = t('model.configured');
|
|
@@ -6066,11 +7167,10 @@ export class SshTui {
|
|
|
6066
7167
|
}
|
|
6067
7168
|
/** /submodel: pick (or set) the model subagent children use. */
|
|
6068
7169
|
async runSubmodelCommand(arg) {
|
|
6069
|
-
const provider = this.effectiveSubagentProvider();
|
|
6070
7170
|
const current = this.subagentSelection.current;
|
|
6071
7171
|
const direct = arg.trim();
|
|
6072
7172
|
if (direct.toLowerCase() === 'reset' || direct === '跟随' || direct === '默认') {
|
|
6073
|
-
const parentProvider = this.currentProviderId();
|
|
7173
|
+
const parentProvider = this.displayProviderId(this.currentProviderId());
|
|
6074
7174
|
const nextModel = defaultSubagentModelForProvider(parentProvider, [], this.selectionRef?.current?.model ?? this.agent.options.model);
|
|
6075
7175
|
const persisted = await this.saveSubagentSelection({
|
|
6076
7176
|
model: nextModel,
|
|
@@ -6083,8 +7183,42 @@ export class SshTui {
|
|
|
6083
7183
|
this.markDirty();
|
|
6084
7184
|
return;
|
|
6085
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());
|
|
6086
7196
|
let selectedId = direct;
|
|
6087
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;
|
|
6088
7222
|
const { options, source } = await this.subagentModelOptions(provider);
|
|
6089
7223
|
if (options.length === 0) {
|
|
6090
7224
|
options.push({ id: current.model, label: current.model });
|
|
@@ -6094,14 +7228,30 @@ export class SshTui {
|
|
|
6094
7228
|
return;
|
|
6095
7229
|
selectedId = selected.id;
|
|
6096
7230
|
}
|
|
6097
|
-
|
|
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)))
|
|
6098
7242
|
return;
|
|
6099
|
-
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);
|
|
6100
7250
|
this.pushRow({
|
|
6101
7251
|
kind: 'system',
|
|
6102
|
-
text: (
|
|
6103
|
-
? t('sub.
|
|
6104
|
-
: t('sub.
|
|
7252
|
+
text: (options.pinProvider
|
|
7253
|
+
? t('sub.modelPinned', { model: modelId, provider: display })
|
|
7254
|
+
: t('sub.modelFollow', { model: modelId, provider: display }))
|
|
6105
7255
|
+ (persisted ? '' : t('sub.sessionOnly')),
|
|
6106
7256
|
});
|
|
6107
7257
|
this.markDirty();
|
|
@@ -6109,6 +7259,7 @@ export class SshTui {
|
|
|
6109
7259
|
/** /effort: pick or set the reasoning effort for the current model. */
|
|
6110
7260
|
async runEffortCommand(arg) {
|
|
6111
7261
|
const provider = this.currentProviderId();
|
|
7262
|
+
const display = this.displayProviderId(provider);
|
|
6112
7263
|
const current = this.selectionRef?.current;
|
|
6113
7264
|
if (current === undefined || current.model === undefined) {
|
|
6114
7265
|
this.pushRow({ kind: 'error', text: t('effort.noModel') });
|
|
@@ -6165,8 +7316,8 @@ export class SshTui {
|
|
|
6165
7316
|
const answer = await this.askQuestion({
|
|
6166
7317
|
id: 'effort-pick',
|
|
6167
7318
|
question: isUndeclared
|
|
6168
|
-
? t('effort.pickCurrentUndeclared', { provider, model: modelId })
|
|
6169
|
-
: t('effort.pickCurrent', { provider, model: modelId }),
|
|
7319
|
+
? t('effort.pickCurrentUndeclared', { provider: display, model: modelId })
|
|
7320
|
+
: t('effort.pickCurrent', { provider: display, model: modelId }),
|
|
6170
7321
|
options: choices.map(c => ({
|
|
6171
7322
|
label: c.label,
|
|
6172
7323
|
description: c.id === currentEffort ? t('disconnect.current') : c.desc,
|
|
@@ -6940,6 +8091,9 @@ export class SshTui {
|
|
|
6940
8091
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
|
|
6941
8092
|
return;
|
|
6942
8093
|
}
|
|
8094
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(-1)) {
|
|
8095
|
+
return;
|
|
8096
|
+
}
|
|
6943
8097
|
else if (this.suggestionsVisible()) {
|
|
6944
8098
|
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
6945
8099
|
this.markDirty();
|
|
@@ -6961,6 +8115,9 @@ export class SshTui {
|
|
|
6961
8115
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
|
|
6962
8116
|
return;
|
|
6963
8117
|
}
|
|
8118
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(1)) {
|
|
8119
|
+
return;
|
|
8120
|
+
}
|
|
6964
8121
|
else if (this.suggestionsVisible()) {
|
|
6965
8122
|
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
6966
8123
|
this.markDirty();
|
|
@@ -7416,8 +8573,8 @@ export class SshTui {
|
|
|
7416
8573
|
const templates = providerTemplates();
|
|
7417
8574
|
const templateEntries = [
|
|
7418
8575
|
{ key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
|
|
7419
|
-
{ key: 'template:opencode-go', label: templates['opencode-go'].label, detail: '
|
|
7420
|
-
{ 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') },
|
|
7421
8578
|
{ key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
|
|
7422
8579
|
{ key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
|
|
7423
8580
|
{ key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
|
|
@@ -7464,6 +8621,108 @@ export class SshTui {
|
|
|
7464
8621
|
}
|
|
7465
8622
|
return sized;
|
|
7466
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
|
+
}
|
|
7467
8726
|
handleOnboardingChar(text) {
|
|
7468
8727
|
const state = this.onboarding;
|
|
7469
8728
|
if (state === undefined)
|
|
@@ -7523,7 +8782,77 @@ export class SshTui {
|
|
|
7523
8782
|
case 'base-url':
|
|
7524
8783
|
case 'key':
|
|
7525
8784
|
case 'models':
|
|
8785
|
+
case 'models-pick':
|
|
8786
|
+
case 'model-default':
|
|
7526
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
|
+
}
|
|
7527
8856
|
if (state.step === 'models' && text === '\x06') {
|
|
7528
8857
|
void this.fetchOnboardingModels();
|
|
7529
8858
|
return;
|
|
@@ -7549,19 +8878,26 @@ export class SshTui {
|
|
|
7549
8878
|
state.key = value;
|
|
7550
8879
|
}
|
|
7551
8880
|
else if (state.step === 'models') {
|
|
7552
|
-
|
|
7553
|
-
//
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
:
|
|
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);
|
|
7559
8894
|
if (parsed.length === 0) {
|
|
7560
8895
|
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
7561
8896
|
this.markDirty();
|
|
7562
8897
|
return;
|
|
7563
8898
|
}
|
|
7564
8899
|
state.models = parsed;
|
|
8900
|
+
state.defaultModel = parsed[0];
|
|
7565
8901
|
}
|
|
7566
8902
|
else if (state.step === 'context') {
|
|
7567
8903
|
// Enter keeps the pre-filled value; a typed number overrides it.
|
|
@@ -7608,6 +8944,12 @@ export class SshTui {
|
|
|
7608
8944
|
state.baseUrl = '';
|
|
7609
8945
|
state.key = '';
|
|
7610
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;
|
|
7611
8953
|
state.modelCapacity = new Map();
|
|
7612
8954
|
this.input = '';
|
|
7613
8955
|
this.cursor = 0;
|
|
@@ -7632,21 +8974,11 @@ export class SshTui {
|
|
|
7632
8974
|
else if (state.step === 'key') {
|
|
7633
8975
|
state.step = 'models';
|
|
7634
8976
|
}
|
|
7635
|
-
else if (state.step
|
|
7636
|
-
//
|
|
7637
|
-
//
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
const sized = this.sizeOnboardingModels(state);
|
|
7641
|
-
const unsized = state.models.filter(id => !sized.has(id));
|
|
7642
|
-
if (unsized.length === 0) {
|
|
7643
|
-
state.step = 'confirm';
|
|
7644
|
-
}
|
|
7645
|
-
else {
|
|
7646
|
-
state.routeContextWindow = suggestedRouteContextWindow(sized.values())
|
|
7647
|
-
?? HARNESS_DEFAULT_CONTEXT_WINDOW;
|
|
7648
|
-
state.step = 'context';
|
|
7649
|
-
}
|
|
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;
|
|
7650
8982
|
}
|
|
7651
8983
|
else if (state.step === 'context') {
|
|
7652
8984
|
state.step = 'confirm';
|
|
@@ -7655,6 +8987,92 @@ export class SshTui {
|
|
|
7655
8987
|
this.cursor = 0;
|
|
7656
8988
|
this.markDirty();
|
|
7657
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
|
+
}
|
|
7658
9076
|
/** Fetch the endpoint's model list into the onboarding wizard's models step. */
|
|
7659
9077
|
async fetchOnboardingModels() {
|
|
7660
9078
|
const state = this.onboarding;
|
|
@@ -7699,6 +9117,11 @@ export class SshTui {
|
|
|
7699
9117
|
this.pushRow({ kind: 'error', text: t('onboard.noModels') });
|
|
7700
9118
|
}
|
|
7701
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
|
+
}
|
|
7702
9125
|
state.models = ids;
|
|
7703
9126
|
state.modelCapacity = new Map(discovered.flatMap(model => {
|
|
7704
9127
|
const capacity = {
|
|
@@ -7724,6 +9147,10 @@ export class SshTui {
|
|
|
7724
9147
|
const state = this.onboarding;
|
|
7725
9148
|
if (state === undefined)
|
|
7726
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();
|
|
7727
9154
|
let saved = true;
|
|
7728
9155
|
try {
|
|
7729
9156
|
const credentials = this.ctx.get('credentials');
|
|
@@ -7732,14 +9159,14 @@ export class SshTui {
|
|
|
7732
9159
|
if (state.providerType === 'official') {
|
|
7733
9160
|
const envRef = 'DEEPSEEK_API_KEY';
|
|
7734
9161
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7735
|
-
const model = state.models[0] ?? 'deepseek-v4-pro';
|
|
9162
|
+
const model = state.defaultModel ?? state.models[0] ?? 'deepseek-v4-pro';
|
|
7736
9163
|
await this.ctx.get('agentDefaultModel')?.saveSelection({ provider: 'deepseek-official', model });
|
|
7737
9164
|
if (this.selectionRef !== undefined) {
|
|
7738
9165
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
7739
9166
|
}
|
|
7740
9167
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
7741
9168
|
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
7742
|
-
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
9169
|
+
await this.syncSubagentToProvider('deepseek-official', state.models, previousParentProvider !== 'deepseek-official');
|
|
7743
9170
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
7744
9171
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
7745
9172
|
this.pushRow({ kind: 'system', text: t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }) });
|
|
@@ -7753,7 +9180,7 @@ export class SshTui {
|
|
|
7753
9180
|
}
|
|
7754
9181
|
else {
|
|
7755
9182
|
const envRef = envRefForId(state.providerId);
|
|
7756
|
-
const model = state.models[0];
|
|
9183
|
+
const model = state.defaultModel ?? state.models[0];
|
|
7757
9184
|
// OpenCode / third-party (llm-pi-ai) routes have no adapter-level
|
|
7758
9185
|
// reasoning default. Re-running setup must not silently drop the
|
|
7759
9186
|
// effort that makes thinking arrive as `reasoning` blocks; default it
|
|
@@ -7769,7 +9196,44 @@ export class SshTui {
|
|
|
7769
9196
|
: await defaultReasoningEffort(llm, state.providerId, model);
|
|
7770
9197
|
const reasoningEfforts = onboardingReasoningEfforts(state.providerType, defaultEffort === undefined ? undefined : String(defaultEffort));
|
|
7771
9198
|
const existing = this.piAiProviderProfile(state.providerId);
|
|
7772
|
-
|
|
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);
|
|
7773
9237
|
// The confirm step can be reached without walking the models step (and
|
|
7774
9238
|
// a saved catalog answer may have landed after it), so size the picks
|
|
7775
9239
|
// here too: the call is idempotent and fills only missing windows.
|
|
@@ -7798,36 +9262,74 @@ export class SshTui {
|
|
|
7798
9262
|
// and an absent key defers to the provider's own environment auth.
|
|
7799
9263
|
const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
|
|
7800
9264
|
const keyless = catalogRoute && state.key === '';
|
|
7801
|
-
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) => ({
|
|
7802
9283
|
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
7803
9284
|
? existing.displayName
|
|
7804
9285
|
: template.label,
|
|
7805
9286
|
...(keyless ? {} : { apiKeyEnv: envRef }),
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
: state.baseUrl,
|
|
7813
|
-
}),
|
|
7814
|
-
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 => ({
|
|
7815
9293
|
id,
|
|
7816
9294
|
...(state.modelCapacity?.get(id) ?? template.defaultModelCapacity?.[id] ?? {}),
|
|
7817
9295
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
7818
9296
|
})),
|
|
7819
9297
|
...(state.routeContextWindow === undefined ? {} : { defaultContextWindow: state.routeContextWindow }),
|
|
7820
9298
|
...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
|
|
7821
|
-
};
|
|
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);
|
|
7822
9318
|
if (settings === undefined) {
|
|
7823
9319
|
this.pushRow({ kind: 'error', text: t('onboard.settingsMissing') });
|
|
7824
9320
|
saved = false;
|
|
7825
9321
|
}
|
|
7826
9322
|
else {
|
|
7827
|
-
await settings.mutate(settingsNamespace('llm-pi-ai'),
|
|
7828
|
-
|
|
7829
|
-
|
|
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
|
+
})));
|
|
7830
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
|
+
}
|
|
7831
9333
|
}
|
|
7832
9334
|
// Only store the key when its provider profile actually made it to
|
|
7833
9335
|
// settings; otherwise the saved key points at an unusable route.
|
|
@@ -7835,7 +9337,7 @@ export class SshTui {
|
|
|
7835
9337
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7836
9338
|
if (saved) {
|
|
7837
9339
|
const selection = {
|
|
7838
|
-
provider:
|
|
9340
|
+
provider: ownerId,
|
|
7839
9341
|
model,
|
|
7840
9342
|
...(defaultEffort === undefined ? {} : { reasoningEffort: defaultEffort }),
|
|
7841
9343
|
};
|
|
@@ -7845,10 +9347,10 @@ export class SshTui {
|
|
|
7845
9347
|
}
|
|
7846
9348
|
this.onSelectionChanged?.(selection);
|
|
7847
9349
|
await this.rememberRoute(selection);
|
|
7848
|
-
await this.syncSubagentToProvider(
|
|
9350
|
+
await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
|
|
7849
9351
|
this.pushRow({
|
|
7850
9352
|
kind: 'system',
|
|
7851
|
-
text: t('onboard.customDone', { id:
|
|
9353
|
+
text: t('onboard.customDone', { id: ownerId, model }),
|
|
7852
9354
|
});
|
|
7853
9355
|
}
|
|
7854
9356
|
}
|
|
@@ -7998,8 +9500,12 @@ export class SshTui {
|
|
|
7998
9500
|
}
|
|
7999
9501
|
if (this.dialog.kind === 'confirm')
|
|
8000
9502
|
this.closeConfirm('cancel');
|
|
8001
|
-
else if (this.dialog.kind === 'onboarding')
|
|
8002
|
-
|
|
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
|
+
}
|
|
8003
9509
|
else
|
|
8004
9510
|
this.dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
8005
9511
|
return;
|
|
@@ -8285,6 +9791,10 @@ export class SshTui {
|
|
|
8285
9791
|
const text = this.input.trim();
|
|
8286
9792
|
if (text === '')
|
|
8287
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 }) });
|
|
8288
9798
|
if (text.startsWith('/')) {
|
|
8289
9799
|
this.historyIndex = this.history.length;
|
|
8290
9800
|
this.historyDraft = '';
|
|
@@ -8499,7 +10009,7 @@ export class SshTui {
|
|
|
8499
10009
|
...(quota === undefined ? {} : { quota }),
|
|
8500
10010
|
...(this.contextPressure === undefined ? {} : { context: this.contextPressure }),
|
|
8501
10011
|
parentModel: model,
|
|
8502
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
10012
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
8503
10013
|
subModel: sub.model,
|
|
8504
10014
|
cwd: this.workspaceCwd(),
|
|
8505
10015
|
});
|
|
@@ -8563,16 +10073,20 @@ export class SshTui {
|
|
|
8563
10073
|
this.pushRow({ kind: 'system', text: t('sub.none') });
|
|
8564
10074
|
}
|
|
8565
10075
|
else {
|
|
8566
|
-
const lines = [...this.activeSubagents.entries()].map(([
|
|
10076
|
+
const lines = [...this.activeSubagents.entries()].map(([, sub]) => {
|
|
8567
10077
|
const card = this.findSubagentRow(sub.id);
|
|
8568
|
-
const label = card
|
|
8569
|
-
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
|
+
})();
|
|
8570
10083
|
return t('sub.listLine', {
|
|
8571
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.
|
|
8572
10087
|
id: sub.id,
|
|
8573
10088
|
provider: sub.provider,
|
|
8574
10089
|
seconds: Math.floor((Date.now() - sub.startedAt) / 1000),
|
|
8575
|
-
run: runId.slice(0, 8),
|
|
8576
10090
|
activity,
|
|
8577
10091
|
});
|
|
8578
10092
|
});
|