dsh-ssh-tui 0.7.1 → 0.7.3
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 +92 -8
- package/README.md +64 -8
- package/cordis.patch.yml +15 -0
- package/docs/terminals.md +113 -0
- 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 +76 -17
- package/lib/diag.js.map +1 -1
- package/lib/dialogs.js.map +1 -1
- package/lib/display-sock.js +304 -29
- package/lib/display-sock.js.map +1 -1
- package/lib/doctor.js +71 -37
- package/lib/doctor.js.map +1 -1
- package/lib/dsh-compat.js +206 -88
- package/lib/dsh-compat.js.map +1 -1
- package/lib/footer.js +39 -9
- 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 +79 -41
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +13 -7
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +79 -41
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +132 -31
- package/lib/index.js.map +1 -1
- package/lib/job-label.js +99 -17
- package/lib/job-label.js.map +1 -1
- package/lib/paint.js +5 -2
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +5 -3
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +269 -17
- package/lib/plan.js.map +1 -1
- package/lib/platform.js +373 -0
- package/lib/platform.js.map +1 -0
- package/lib/preset-authoring.js +10 -14
- package/lib/preset-authoring.js.map +1 -1
- package/lib/preset-compat.js +100 -0
- package/lib/preset-compat.js.map +1 -0
- package/lib/preset-picker.js +5 -1
- package/lib/preset-picker.js.map +1 -1
- package/lib/preset-rows.js +83 -13
- package/lib/preset-rows.js.map +1 -1
- package/lib/provider-catalog.js +7 -4
- package/lib/provider-catalog.js.map +1 -1
- package/lib/route-memory.js +11 -4
- package/lib/route-memory.js.map +1 -1
- package/lib/session-index.js +5 -0
- package/lib/session-index.js.map +1 -1
- package/lib/session-list.js +18 -1
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +79 -24
- package/lib/session-lock.js.map +1 -1
- package/lib/session-route.js +334 -0
- package/lib/session-route.js.map +1 -0
- package/lib/settings-routes.js +10 -0
- package/lib/settings-routes.js.map +1 -0
- package/lib/settings-subagent.js +10 -0
- package/lib/settings-subagent.js.map +1 -0
- package/lib/subagent-model.js +84 -6
- package/lib/subagent-model.js.map +1 -1
- package/lib/supergrok-token.js +4 -0
- package/lib/supergrok-token.js.map +1 -1
- package/lib/term-text.js +7 -4
- package/lib/term-text.js.map +1 -1
- package/lib/terminal-caps.js +358 -0
- package/lib/terminal-caps.js.map +1 -0
- package/lib/tool-present.js +46 -14
- package/lib/tool-present.js.map +1 -1
- package/lib/tui.js +1949 -371
- package/lib/tui.js.map +1 -1
- package/lib/types/color-depth.d.ts +3 -3
- package/lib/types/diag.d.ts +18 -1
- package/lib/types/dialogs.d.ts +4 -0
- package/lib/types/display-sock.d.ts +94 -2
- package/lib/types/doctor.d.ts +8 -1
- package/lib/types/dsh-compat.d.ts +141 -44
- package/lib/types/footer.d.ts +21 -5
- package/lib/types/gateway-protocol.d.ts +88 -0
- package/lib/types/i18n/index.d.ts +18 -12
- package/lib/types/index.d.ts +45 -0
- package/lib/types/job-label.d.ts +38 -7
- package/lib/types/picker.d.ts +3 -0
- package/lib/types/plan.d.ts +94 -4
- package/lib/types/platform.d.ts +215 -0
- package/lib/types/preset-authoring.d.ts +8 -14
- package/lib/types/preset-compat.d.ts +58 -0
- package/lib/types/preset-picker.d.ts +1 -1
- package/lib/types/preset-rows.d.ts +51 -9
- 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/settings-routes.d.ts +16 -0
- package/lib/types/settings-subagent.d.ts +24 -0
- package/lib/types/subagent-model.d.ts +60 -7
- package/lib/types/term-text.d.ts +3 -2
- package/lib/types/terminal-caps.d.ts +105 -0
- package/lib/types/tool-present.d.ts +2 -17
- package/lib/types/transcript-types.d.ts +44 -1
- package/lib/types/tui.d.ts +333 -9
- package/package.json +82 -55
package/lib/tui.js
CHANGED
|
@@ -22,11 +22,11 @@ import { homedir } from 'node:os';
|
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
24
|
import { StringDecoder } from 'node:string_decoder';
|
|
25
|
-
import { METADATA_FILE, readPresetMetadata } from '
|
|
25
|
+
import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
|
|
26
26
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
27
27
|
import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
28
28
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
29
|
-
import { commandAcceptsAttachments, forEachSessionEventAsync,
|
|
29
|
+
import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, } from './dsh-compat.js';
|
|
30
30
|
import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
|
|
31
31
|
import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
|
|
32
32
|
import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
|
|
@@ -41,44 +41,78 @@ import { keymapReport, resolveKeymap } from './keymap.js';
|
|
|
41
41
|
import { presetLabel, profileFromArgv } from './preset-label.js';
|
|
42
42
|
import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
43
43
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
44
|
-
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup,
|
|
44
|
+
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
|
|
45
45
|
import { SessionStatsTracker, statsRowOf } from './stats.js';
|
|
46
|
-
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 { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
|
|
51
|
+
import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
|
|
52
|
+
import { sameSessionRoute, sessionRouteInput } from './session-route.js';
|
|
53
|
+
import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
|
|
50
54
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
51
55
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
52
56
|
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
53
57
|
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
54
|
-
import {
|
|
55
|
-
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
58
|
+
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentProviderDiffers, subagentSettingsValue, } from './subagent-model.js';
|
|
56
59
|
import { resolveFreshSuperGrokToken } from './supergrok-token.js';
|
|
57
60
|
import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
|
|
61
|
+
import { displayToolName, subagentCourtesyName } from './job-label.js';
|
|
58
62
|
import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
|
|
59
63
|
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
64
|
import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
|
|
61
65
|
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
66
|
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';
|
|
67
|
+
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
68
|
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';
|
|
69
|
+
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';
|
|
70
|
+
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
71
|
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
72
|
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';
|
|
73
|
+
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
74
|
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
|
-
export { commandAcceptsAttachments, forEachSessionEvent,
|
|
72
|
-
export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle,
|
|
75
|
+
export { commandAcceptsAttachments, forEachSessionEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
|
|
76
|
+
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
77
|
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
78
|
function discoverProviderModels(llm, request, signal) {
|
|
75
|
-
return llm.discoverModels(settingsNamespace('llm-pi-ai'),
|
|
79
|
+
return llm.discoverModels(settingsNamespace('llm-pi-ai'), request, signal);
|
|
76
80
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Ask a gateway which routes each of its models answers (`supported_endpoints`
|
|
83
|
+
* in an OpenAI-shaped `GET {baseURL}/models` reply). Command Code publishes
|
|
84
|
+
* them; most gateways do not, and the split then falls back to the built-in
|
|
85
|
+
* table. Best-effort by design: no key, no field, a network error or a shape we
|
|
86
|
+
* do not recognise all mean "no answer", never a failed setup.
|
|
87
|
+
*/
|
|
88
|
+
async function fetchModelEndpoints(baseURL, apiKey, signal) {
|
|
89
|
+
if (baseURL === '' || apiKey === '')
|
|
90
|
+
return undefined;
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(`${baseURL.replace(/\/+$/u, '')}/models`, {
|
|
93
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
|
|
94
|
+
// An outer signal (a picker that must not stall) wins when it is shorter.
|
|
95
|
+
signal: signal ?? AbortSignal.timeout(10_000),
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok)
|
|
98
|
+
return undefined;
|
|
99
|
+
const body = (await response.json());
|
|
100
|
+
const endpoints = new Map();
|
|
101
|
+
for (const entry of body.data ?? []) {
|
|
102
|
+
const id = typeof entry?.id === 'string' ? entry.id : '';
|
|
103
|
+
const list = Array.isArray(entry?.supported_endpoints)
|
|
104
|
+
? entry.supported_endpoints.filter((value) => typeof value === 'string')
|
|
105
|
+
: [];
|
|
106
|
+
if (id !== '' && list.length > 0)
|
|
107
|
+
endpoints.set(id, list);
|
|
108
|
+
}
|
|
109
|
+
return endpoints.size === 0 ? undefined : endpoints;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
81
113
|
}
|
|
114
|
+
}
|
|
115
|
+
function installUserQuestionAnswerer(ctx, ask) {
|
|
82
116
|
return ctx.on('user-questions/request', async (request, next) => {
|
|
83
117
|
try {
|
|
84
118
|
return await ask(request);
|
|
@@ -91,6 +125,16 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
|
|
|
91
125
|
});
|
|
92
126
|
}
|
|
93
127
|
const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
|
|
128
|
+
/**
|
|
129
|
+
* The `agent-default-model` settings entry `/model` falls back to writing.
|
|
130
|
+
*
|
|
131
|
+
* 0.1.5 exports this id as `AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE`; 0.1.7
|
|
132
|
+
* removed the export and let the profile own entry ids, which keeps the name the
|
|
133
|
+
* legacy `settings.yaml` section is imported under. Inlined so one build runs
|
|
134
|
+
* against either host — the primary path, `agentDefaultModel.saveSelection`,
|
|
135
|
+
* is the same call on both.
|
|
136
|
+
*/
|
|
137
|
+
const AGENT_DEFAULT_MODEL_NS = settingsNamespace('agent-default-model');
|
|
94
138
|
/**
|
|
95
139
|
* The window llm-pi-ai falls back to when neither a model entry nor the
|
|
96
140
|
* installed catalog sizes a model. The wizard pre-fills it only so a route
|
|
@@ -105,20 +149,27 @@ function providerTemplates() {
|
|
|
105
149
|
defaultBaseUrl: 'https://api.deepseek.com',
|
|
106
150
|
defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
|
|
107
151
|
},
|
|
152
|
+
// One row, both routes: the wizard files each model under the protocol it
|
|
153
|
+
// speaks (the gateway publishes `supported_endpoints`, and its pinned
|
|
154
|
+
// defaults are verified on responses), so nobody has to pick a protocol by
|
|
155
|
+
// hand or discover later that half the catalogue needs the other one.
|
|
108
156
|
'opencode-go': {
|
|
109
157
|
label: t('onboard.providerGo'),
|
|
110
158
|
defaultId: 'opencode-go',
|
|
111
159
|
defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
|
|
112
160
|
api: 'openai-responses',
|
|
161
|
+
protocols: ['openai-responses', 'openai-completions', 'anthropic-messages'],
|
|
113
162
|
defaultModels: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
|
114
163
|
},
|
|
115
|
-
//
|
|
116
|
-
//
|
|
164
|
+
// Same shape for Command Code: its chat route bills through the API root's
|
|
165
|
+
// `/alpha/billing/credits`, and its catalogue is split across three
|
|
166
|
+
// protocols, which the splitter reads off `GET /provider/v1/models`.
|
|
117
167
|
'command-code': {
|
|
118
168
|
label: t('onboard.providerCommandCode'),
|
|
119
169
|
defaultId: 'command-code',
|
|
120
170
|
defaultBaseUrl: 'https://api.commandcode.ai/provider/v1',
|
|
121
|
-
api: 'openai-
|
|
171
|
+
api: 'openai-responses',
|
|
172
|
+
protocols: ['openai-responses', 'openai-completions', 'anthropic-messages'],
|
|
122
173
|
defaultModels: ['deepseek/deepseek-v4.1-flash'],
|
|
123
174
|
defaultModelCapacity: { 'deepseek/deepseek-v4.1-flash': { contextWindow: 1_048_576 } },
|
|
124
175
|
},
|
|
@@ -160,6 +211,23 @@ function onboardTemplate(state) {
|
|
|
160
211
|
}
|
|
161
212
|
return providerTemplates()[state.providerType];
|
|
162
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* The wizard steps that choose models, however they are presented: the id
|
|
216
|
+
* prompt, the multi-select picker, and the session-model pick.
|
|
217
|
+
*/
|
|
218
|
+
function isModelsStep(step) {
|
|
219
|
+
return step === 'models' || step === 'models-pick' || step === 'model-default';
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* How long a compaction may claim to be running before it is treated as
|
|
223
|
+
* abandoned. A real one finishes in seconds to a couple of minutes; the Host
|
|
224
|
+
* dying mid-compaction leaves `compaction/start` in the log with no end, and
|
|
225
|
+
* that row would otherwise keep the footer on 压缩中 and refuse /compact for
|
|
226
|
+
* the rest of the session's life.
|
|
227
|
+
*/
|
|
228
|
+
const COMPACTION_STALE_MS = 15 * 60_000;
|
|
229
|
+
/** How many `<family>\0<model>` → row decisions are remembered. */
|
|
230
|
+
const FAMILY_OWNER_CACHE_MAX = 512;
|
|
163
231
|
/**
|
|
164
232
|
* A short duration in the shortest useful unit: `12s` / `3m` / `1h`.
|
|
165
233
|
* Locale-neutral on purpose — it reads the same in both catalogs.
|
|
@@ -204,6 +272,17 @@ const PLUGIN_VERSION = (() => {
|
|
|
204
272
|
})();
|
|
205
273
|
/** Files named on a collapsed compact burst before the rest are counted. */
|
|
206
274
|
const MAX_COMPACT_FILE_STATS = 4;
|
|
275
|
+
/** Lines of an inspect body that line mode writes into the log before trimming. */
|
|
276
|
+
const LINE_MODE_INSPECT_LINES = 80;
|
|
277
|
+
/**
|
|
278
|
+
* Emphasis inside a filled diff row: a lighter shade of the row's own fill, so
|
|
279
|
+
* a changed word is marked without stepping outside the muted palette. The
|
|
280
|
+
* fills they lighten live in `styleLine` (`diff-add` / `diff-del`).
|
|
281
|
+
*/
|
|
282
|
+
const DIFF_EMPHASIS_SGR = {
|
|
283
|
+
'diff-add': '38;2;198;232;190;48;2;34;72;44',
|
|
284
|
+
'diff-del': '38;2;246;206;206;48;2;86;34;34',
|
|
285
|
+
};
|
|
207
286
|
const STALL_WARNING_MS = 60000;
|
|
208
287
|
/** Bytes already queued for the terminal before a frame is skipped instead. */
|
|
209
288
|
const STDOUT_BACKLOG_BYTES = 32 * 1024;
|
|
@@ -236,7 +315,6 @@ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
|
|
|
236
315
|
*/
|
|
237
316
|
const QUOTA_RETRY_MS = 15_000;
|
|
238
317
|
const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
|
|
239
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
240
318
|
function dshHomeDir() {
|
|
241
319
|
return resolveDshHome();
|
|
242
320
|
}
|
|
@@ -259,23 +337,9 @@ function hostDshVersion() {
|
|
|
259
337
|
return 'unknown';
|
|
260
338
|
}
|
|
261
339
|
function displayDshPath(file) {
|
|
262
|
-
|
|
263
|
-
if (IS_WINDOWS) {
|
|
264
|
-
const profile = process.env.USERPROFILE;
|
|
265
|
-
if (profile !== undefined && home.toLowerCase().startsWith(profile.toLowerCase())) {
|
|
266
|
-
const rest = home.slice(profile.length);
|
|
267
|
-
return `%USERPROFILE%${rest}\\${file}`.replaceAll('/', '\\');
|
|
268
|
-
}
|
|
269
|
-
return `${home}\\${file}`.replaceAll('/', '\\');
|
|
270
|
-
}
|
|
271
|
-
const userHome = homedir();
|
|
272
|
-
if (home === userHome)
|
|
273
|
-
return `~/.dsh/${file}`;
|
|
274
|
-
if (home.startsWith(`${userHome}/`))
|
|
275
|
-
return `~/${home.slice(userHome.length + 1)}/${file}`;
|
|
276
|
-
return join(home, file);
|
|
340
|
+
return displayHomePath(dshHomeDir(), file);
|
|
277
341
|
}
|
|
278
|
-
const DSH_ENV_FILE = join(dshHomeDir(),
|
|
342
|
+
const DSH_ENV_FILE = join(dshHomeDir(), envFileName());
|
|
279
343
|
const DEEPSEEK_LOGO_VARIANTS = [
|
|
280
344
|
{
|
|
281
345
|
width: 52,
|
|
@@ -478,6 +542,22 @@ export function parseWorkspaceView(raw) {
|
|
|
478
542
|
return undefined;
|
|
479
543
|
}
|
|
480
544
|
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
545
|
+
/** One line naming the route a resumed session came back on. */
|
|
546
|
+
function sessionRouteNotice(route) {
|
|
547
|
+
const effort = route.reasoningEffort === undefined || route.reasoningEffort === ''
|
|
548
|
+
? ''
|
|
549
|
+
: ` (${route.reasoningEffort})`;
|
|
550
|
+
const subagent = route.subagent;
|
|
551
|
+
const sub = subagent === undefined
|
|
552
|
+
? ''
|
|
553
|
+
: t('session.routeSubagent', {
|
|
554
|
+
route: `${subagent.provider === undefined ? '' : `${subagent.provider}/`}${subagent.model}`
|
|
555
|
+
+ (subagent.reasoningEffort === undefined || subagent.reasoningEffort === ''
|
|
556
|
+
? ''
|
|
557
|
+
: `(${subagent.reasoningEffort})`),
|
|
558
|
+
});
|
|
559
|
+
return t('session.routeRestored', { route: `${route.provider}/${route.model}${effort}`, sub });
|
|
560
|
+
}
|
|
481
561
|
/** Owns one interactive terminal channel and its agent event wiring. */
|
|
482
562
|
export class SshTui {
|
|
483
563
|
ctx;
|
|
@@ -500,6 +580,19 @@ export class SshTui {
|
|
|
500
580
|
catalogLoad;
|
|
501
581
|
/** Memoized id→window index over {@link catalogPresets}. */
|
|
502
582
|
catalogWindows;
|
|
583
|
+
/** Wizard drafts whose models step is already asking the gateway, so a
|
|
584
|
+
* second Enter cannot start a duplicate listing. */
|
|
585
|
+
finishingModels = new WeakSet();
|
|
586
|
+
/** `<base>\0<model>` → the family row last seen serving that model. */
|
|
587
|
+
/**
|
|
588
|
+
* `<base>\0<model>` → the family row last seen serving that model.
|
|
589
|
+
*
|
|
590
|
+
* Bounded: a gateway can publish hundreds of models and their rows are
|
|
591
|
+
* re-learned on every listing, so the oldest entries are dropped once the map
|
|
592
|
+
* passes FAMILY_OWNER_CACHE_MAX rather than growing for the life of the
|
|
593
|
+
* session. Losing an entry only costs one future "which row?" lookup.
|
|
594
|
+
*/
|
|
595
|
+
familyModelOwner = new Map();
|
|
503
596
|
input = '';
|
|
504
597
|
cursor = 0;
|
|
505
598
|
inputFolded = false;
|
|
@@ -545,11 +638,22 @@ export class SshTui {
|
|
|
545
638
|
selectionRef;
|
|
546
639
|
subagentSelection;
|
|
547
640
|
onSelectionChanged;
|
|
641
|
+
onRouteSettled;
|
|
548
642
|
disposers = [];
|
|
549
643
|
userQuestionDisposer;
|
|
550
644
|
presetId = 'standard';
|
|
551
645
|
presetName = t('mode.preset.standard');
|
|
646
|
+
/** Last route reported to the launcher; a repaint must not report it again. */
|
|
647
|
+
lastSessionRoute;
|
|
552
648
|
useAlternateScreen;
|
|
649
|
+
/**
|
|
650
|
+
* What the terminal in front of us can do. Read from the environment at
|
|
651
|
+
* construction (see `terminal-caps.ts`): the mouse, bracketed paste and the
|
|
652
|
+
* alternate screen are only claimed where the terminal has them.
|
|
653
|
+
*/
|
|
654
|
+
terminalCaps;
|
|
655
|
+
/** The OSC 52 caveat is worth saying once, not after every copy. */
|
|
656
|
+
osc52HintShown = false;
|
|
553
657
|
agentGone = false;
|
|
554
658
|
onboarding;
|
|
555
659
|
commandSuggestions = [];
|
|
@@ -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;
|
|
@@ -734,8 +842,8 @@ export class SshTui {
|
|
|
734
842
|
this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
|
|
735
843
|
this.presetId = config.presetId ?? 'standard';
|
|
736
844
|
this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
|
|
737
|
-
this.
|
|
738
|
-
|
|
845
|
+
this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
|
|
846
|
+
this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
|
|
739
847
|
this.paintLink = detectSshSession() ? 'ssh' : 'local';
|
|
740
848
|
this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
|
|
741
849
|
ssh: this.paintLink === 'ssh',
|
|
@@ -753,11 +861,31 @@ export class SshTui {
|
|
|
753
861
|
}
|
|
754
862
|
this.refreshRosterHealth();
|
|
755
863
|
if (this.rosterMissing) {
|
|
756
|
-
|
|
864
|
+
// The missing thing differs by host line: a roster the profile can mount,
|
|
865
|
+
// or the agent-plane rows a 0.1.7 terminal profile owns. The advice has to
|
|
866
|
+
// match what `/mode fix` will actually write.
|
|
867
|
+
this.pushRow({
|
|
868
|
+
kind: 'system',
|
|
869
|
+
text: t(this.settingsGeneration === 'forms' ? 'mode.bootFormsMissing' : 'mode.bootMissing'),
|
|
870
|
+
});
|
|
757
871
|
}
|
|
758
872
|
if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
|
|
759
873
|
this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
|
|
760
874
|
}
|
|
875
|
+
if (config.restoredRoute !== undefined) {
|
|
876
|
+
this.pushRow({ kind: 'system', text: sessionRouteNotice(config.restoredRoute) });
|
|
877
|
+
}
|
|
878
|
+
// Windows only, and only on the fallback path: with no PowerShell there is
|
|
879
|
+
// no hidden console, so the Host is a direct child and closing the window
|
|
880
|
+
// ends the session's compute. Say it at boot rather than let the user find
|
|
881
|
+
// out by closing the window mid-turn. A bootstrapped Windows Host and every
|
|
882
|
+
// POSIX Host keep running, so neither says anything.
|
|
883
|
+
if (isTuiHostProcess() && !hostHasOwnConsole()) {
|
|
884
|
+
this.pushRow({ kind: 'system', text: t('boot.directHost') });
|
|
885
|
+
}
|
|
886
|
+
for (const notice of config.launchNotices ?? []) {
|
|
887
|
+
this.pushRow({ kind: notice.kind, text: notice.text });
|
|
888
|
+
}
|
|
761
889
|
}
|
|
762
890
|
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
763
891
|
start() {
|
|
@@ -772,14 +900,19 @@ export class SshTui {
|
|
|
772
900
|
});
|
|
773
901
|
if (this.headlessDisplay) {
|
|
774
902
|
this.displayDetached = true;
|
|
775
|
-
|
|
903
|
+
// Line mode has no frames and no window title. The Host still listens on
|
|
904
|
+
// the display socket; typing arrives as FRAME_STDIN, not process.stdin.
|
|
905
|
+
// Starting the paint timer here is what leaked OSC titles into the log
|
|
906
|
+
// (`\x1b]0;dsh …\x07`) the moment a turn began.
|
|
907
|
+
if (!this.lineMode)
|
|
908
|
+
this.startRenderTimer();
|
|
776
909
|
this.bootBackgroundTasks();
|
|
777
910
|
return;
|
|
778
911
|
}
|
|
779
912
|
process.stdin.setRawMode(true);
|
|
780
913
|
process.stdin.resume();
|
|
781
914
|
process.stdout.on('resize', this.onDirectResize);
|
|
782
|
-
if (
|
|
915
|
+
if (usesSigwinch()) {
|
|
783
916
|
process.on('SIGWINCH', this.onDirectResize);
|
|
784
917
|
}
|
|
785
918
|
process.stdin.prependListener('end', this.handleHangupStream);
|
|
@@ -787,15 +920,13 @@ export class SshTui {
|
|
|
787
920
|
process.stdout.on('error', this.handleIoError);
|
|
788
921
|
process.stdin.on('error', this.handleIoError);
|
|
789
922
|
if (this.lineMode) {
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
process.stdin.setRawMode(true);
|
|
793
|
-
process.stdin.resume();
|
|
923
|
+
// Direct (non-Host) line mode: typing still works, but there is no
|
|
924
|
+
// screen to paint and no animation to run.
|
|
794
925
|
process.stdin.on('data', this.handleData);
|
|
795
926
|
this.bootBackgroundTasks();
|
|
796
927
|
return;
|
|
797
928
|
}
|
|
798
|
-
this.write(
|
|
929
|
+
this.write(this.enterScreenSequence());
|
|
799
930
|
this.render();
|
|
800
931
|
this.updateTerminalTitle();
|
|
801
932
|
void this.calibratePaintInterval().finally(() => {
|
|
@@ -806,11 +937,22 @@ export class SshTui {
|
|
|
806
937
|
});
|
|
807
938
|
this.bootBackgroundTasks();
|
|
808
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Take over the screen: alternate screen where it is safe, then the input
|
|
942
|
+
* modes the terminal actually supports, then hide the cursor.
|
|
943
|
+
*
|
|
944
|
+
* Joining the parts here (rather than at each call site) is what keeps
|
|
945
|
+
* `start`, a reattach and a teardown from drifting apart — the reattach path
|
|
946
|
+
* used to duplicate this string verbatim.
|
|
947
|
+
*/
|
|
948
|
+
enterScreenSequence() {
|
|
949
|
+
return `${this.useAlternateScreen ? '\x1b[?1049h' : ''}${mouseEnableSequence(this.terminalCaps)}${bracketedPasteSequence(this.terminalCaps, true)}\x1b[?25l`;
|
|
950
|
+
}
|
|
809
951
|
bindAgentEvents() {
|
|
810
952
|
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), listenHostEvent(this.ctx, 'agent/assistant-stream', this.handleAssistantStream), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
811
953
|
const questions = this.ctx.get('userQuestions');
|
|
812
954
|
if (questions !== undefined) {
|
|
813
|
-
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx,
|
|
955
|
+
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, this.handleUserQuestions);
|
|
814
956
|
}
|
|
815
957
|
}
|
|
816
958
|
bootBackgroundTasks() {
|
|
@@ -891,7 +1033,7 @@ export class SshTui {
|
|
|
891
1033
|
}
|
|
892
1034
|
}
|
|
893
1035
|
readSkippedUpdate() {
|
|
894
|
-
const raw = this.ctx
|
|
1036
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
895
1037
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
896
1038
|
return undefined;
|
|
897
1039
|
const skip = raw.skipUpdate;
|
|
@@ -904,14 +1046,14 @@ export class SshTui {
|
|
|
904
1046
|
const settings = this.ctx.get('settings');
|
|
905
1047
|
if (settings === undefined)
|
|
906
1048
|
return;
|
|
907
|
-
const raw =
|
|
1049
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
908
1050
|
const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
909
1051
|
? raw
|
|
910
1052
|
: {};
|
|
911
1053
|
await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
|
|
912
1054
|
}
|
|
913
1055
|
readWorkspaceView() {
|
|
914
|
-
const raw = this.ctx
|
|
1056
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
915
1057
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
916
1058
|
return 'detailed';
|
|
917
1059
|
return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
|
|
@@ -920,7 +1062,7 @@ export class SshTui {
|
|
|
920
1062
|
const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
|
|
921
1063
|
if (env !== undefined)
|
|
922
1064
|
return env;
|
|
923
|
-
const raw = this.ctx
|
|
1065
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
924
1066
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
925
1067
|
return 'pause';
|
|
926
1068
|
return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
|
|
@@ -929,7 +1071,7 @@ export class SshTui {
|
|
|
929
1071
|
const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
|
|
930
1072
|
if (env !== undefined)
|
|
931
1073
|
return env;
|
|
932
|
-
const raw = this.ctx
|
|
1074
|
+
const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
933
1075
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
934
1076
|
return 'off';
|
|
935
1077
|
return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
|
|
@@ -969,7 +1111,7 @@ export class SshTui {
|
|
|
969
1111
|
const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
|
|
970
1112
|
if (Number.isFinite(raw) && raw >= 0)
|
|
971
1113
|
return raw;
|
|
972
|
-
const saved = this.ctx
|
|
1114
|
+
const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
|
|
973
1115
|
if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
974
1116
|
const value = saved.idleExit;
|
|
975
1117
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
@@ -1072,8 +1214,8 @@ export class SshTui {
|
|
|
1072
1214
|
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
1073
1215
|
const styled = headerSegments.length === 0
|
|
1074
1216
|
? this.styleLine('tool', collapsed)
|
|
1075
|
-
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
1076
|
-
addDisplay(this.focusedRow === anchor
|
|
1217
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
1218
|
+
addDisplay(this.selectLine(styled, this.focusedRow === anchor), anchor);
|
|
1077
1219
|
// A collapsed burst is all the reader sees, so the two things that decide
|
|
1078
1220
|
// whether to expand belong on it: which files moved, and what failed.
|
|
1079
1221
|
if (kind === 'edits') {
|
|
@@ -1094,9 +1236,9 @@ export class SshTui {
|
|
|
1094
1236
|
}
|
|
1095
1237
|
const expandedHeaderLines = headerSegments.length === 0
|
|
1096
1238
|
? wrap(header.plain, width).map(line => this.styleLine('tool', line))
|
|
1097
|
-
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
1239
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
|
|
1098
1240
|
for (const wrapped of expandedHeaderLines) {
|
|
1099
|
-
addDisplay(this.focusedRow === anchor
|
|
1241
|
+
addDisplay(this.selectLine(wrapped, this.focusedRow === anchor), anchor);
|
|
1100
1242
|
}
|
|
1101
1243
|
for (const item of items) {
|
|
1102
1244
|
if (kind === 'edits') {
|
|
@@ -1132,7 +1274,7 @@ export class SshTui {
|
|
|
1132
1274
|
|| (row.kind === 'question' && row.status === 'waiting')
|
|
1133
1275
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
1134
1276
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
1135
|
-
|| (row.kind === 'compaction' &&
|
|
1277
|
+
|| (row.kind === 'compaction' && this.compactionRunning()));
|
|
1136
1278
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1137
1279
|
this.dirty = true;
|
|
1138
1280
|
}
|
|
@@ -1186,6 +1328,14 @@ export class SshTui {
|
|
|
1186
1328
|
finally {
|
|
1187
1329
|
this.replaying = false;
|
|
1188
1330
|
this.replayQueue = undefined;
|
|
1331
|
+
// Replay synthesizes chips from parent spawn tools; it never sees
|
|
1332
|
+
// `subagent/start`, so those descriptions must not sit in the live queue
|
|
1333
|
+
// and become the next child's courtesy name after --resume.
|
|
1334
|
+
this.pendingSubagentTasks = [];
|
|
1335
|
+
this.settleUnfinishedReplayChips();
|
|
1336
|
+
// A compaction whose start has no end anywhere in the durable log was
|
|
1337
|
+
// abandoned by the Host; nothing more can arrive for it.
|
|
1338
|
+
this.settleUnfinishedCompactions();
|
|
1189
1339
|
}
|
|
1190
1340
|
for (const item of parked) {
|
|
1191
1341
|
if (this.disposed)
|
|
@@ -1200,6 +1350,36 @@ export class SshTui {
|
|
|
1200
1350
|
this.paintTailBudget = Math.max(24, this.screenRows() * 3);
|
|
1201
1351
|
this.dirty = true;
|
|
1202
1352
|
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Close the chips replay could not settle.
|
|
1355
|
+
*
|
|
1356
|
+
* A spawn `tool/call` with no `tool/result` in the log means the wrapper was
|
|
1357
|
+
* still in flight when the log was last written. Replay only runs in a Host
|
|
1358
|
+
* that has just started, and the child lived in the Host running back then,
|
|
1359
|
+
* so that child is gone and no `subagent/end` will ever arrive for it.
|
|
1360
|
+
* Leaving the chip "running" made every resume of an interrupted turn show a
|
|
1361
|
+
* child counting up forever while `/status` reported none.
|
|
1362
|
+
*
|
|
1363
|
+
* `stopReason: 'unknown'` is also what lets a late live end adopt this row
|
|
1364
|
+
* instead of adding a twin beside it.
|
|
1365
|
+
*/
|
|
1366
|
+
settleUnfinishedReplayChips() {
|
|
1367
|
+
if (this.disposed)
|
|
1368
|
+
return;
|
|
1369
|
+
for (const row of this.rows) {
|
|
1370
|
+
if (row.kind !== 'subagent')
|
|
1371
|
+
continue;
|
|
1372
|
+
if (row.status !== 'running' || row.childSessionId !== undefined)
|
|
1373
|
+
continue;
|
|
1374
|
+
row.status = 'aborted';
|
|
1375
|
+
row.endedAt = row.startedAt;
|
|
1376
|
+
row.stopReason = 'unknown';
|
|
1377
|
+
const text = t('sub.endedMissing');
|
|
1378
|
+
row.lastActivity = text;
|
|
1379
|
+
appendSubagentLog(row, { kind: 'system', text });
|
|
1380
|
+
this.refreshOpenSubagentInspect(row);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1203
1383
|
/** Show the first-launch provider/API-key onboarding when nothing is configured. */
|
|
1204
1384
|
async maybeRunOnboarding() {
|
|
1205
1385
|
const credentials = this.ctx.get('credentials');
|
|
@@ -1269,6 +1449,9 @@ export class SshTui {
|
|
|
1269
1449
|
baseUrl: '',
|
|
1270
1450
|
key: '',
|
|
1271
1451
|
models: [],
|
|
1452
|
+
modelCandidates: [],
|
|
1453
|
+
modelChecked: new Set(),
|
|
1454
|
+
modelCursor: 0,
|
|
1272
1455
|
modelCapacity: new Map(),
|
|
1273
1456
|
catalogPresets: undefined,
|
|
1274
1457
|
catalog: undefined,
|
|
@@ -1344,18 +1527,28 @@ export class SshTui {
|
|
|
1344
1527
|
catch {
|
|
1345
1528
|
// ignore
|
|
1346
1529
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1530
|
+
// Line mode never entered the alternate screen or hid the cursor; writing
|
|
1531
|
+
// the restore sequence here would dump CSI into a `tee` the moment SSH
|
|
1532
|
+
// dropped. The Host's stdout is usually a discarded pipe, but the same
|
|
1533
|
+
// path is shared with a direct (non-Host) line-mode process.
|
|
1534
|
+
if (!this.lineMode) {
|
|
1535
|
+
try {
|
|
1536
|
+
process.stdout.write('\x1b]0;\x07');
|
|
1537
|
+
process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
1538
|
+
// Leave the alternate screen unconditionally, the way every mouse mode is
|
|
1539
|
+
// disabled: `?1049l` on a terminal that never entered is ignored, while
|
|
1540
|
+
// *skipping* it after a reattach whose relay classified the terminal
|
|
1541
|
+
// differently leaves the user stuck in a screen they cannot scroll.
|
|
1542
|
+
process.stdout.write(`${mouseDisableSequence()}${bracketedPasteSequence(this.terminalCaps, false)}\x1b[?25h\x1b[?1049l`);
|
|
1543
|
+
}
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
if (!isHangupErrno(error)) {
|
|
1546
|
+
try {
|
|
1547
|
+
process.stderr.write(`dsh-ssh-tui: failed to restore terminal: ${errorChain(error)}\n`);
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
// both pipes gone
|
|
1551
|
+
}
|
|
1359
1552
|
}
|
|
1360
1553
|
}
|
|
1361
1554
|
}
|
|
@@ -1447,10 +1640,7 @@ export class SshTui {
|
|
|
1447
1640
|
}
|
|
1448
1641
|
/** Routing facts for the doctor's self-consistency check. */
|
|
1449
1642
|
doctorRouting() {
|
|
1450
|
-
const
|
|
1451
|
-
if (settings === undefined)
|
|
1452
|
-
return undefined;
|
|
1453
|
-
const section = settings.get(settingsNamespace('llm-pi-ai'));
|
|
1643
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
1454
1644
|
const providers = section?.providers ?? {};
|
|
1455
1645
|
const provider = this.currentProvider();
|
|
1456
1646
|
const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
|
|
@@ -1461,7 +1651,7 @@ export class SshTui {
|
|
|
1461
1651
|
...(model === undefined ? {} : { model }),
|
|
1462
1652
|
routes: Object.keys(providers),
|
|
1463
1653
|
routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
|
|
1464
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
1654
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
1465
1655
|
...(sub.model === undefined ? {} : { subModel: sub.model }),
|
|
1466
1656
|
};
|
|
1467
1657
|
}
|
|
@@ -1476,6 +1666,7 @@ export class SshTui {
|
|
|
1476
1666
|
roster: this.ctx.get('agentPresets') !== undefined,
|
|
1477
1667
|
codeRuntime: this.hasHostService('codeRuntime'),
|
|
1478
1668
|
},
|
|
1669
|
+
generation: hostSettingsGeneration(this.ctx),
|
|
1479
1670
|
anchors: [process.argv[1], this.ctx.baseUrl],
|
|
1480
1671
|
...(routing === undefined ? {} : { routing }),
|
|
1481
1672
|
});
|
|
@@ -1496,11 +1687,11 @@ export class SshTui {
|
|
|
1496
1687
|
/** `/fix <row>`: repair one named roster row of the profile patch. */
|
|
1497
1688
|
async runFixCommand(arg) {
|
|
1498
1689
|
const name = arg.trim().toLowerCase();
|
|
1499
|
-
const row =
|
|
1690
|
+
const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
|
|
1500
1691
|
if (row === undefined) {
|
|
1501
1692
|
this.pushRow({
|
|
1502
1693
|
kind: 'error',
|
|
1503
|
-
text: t('doctor.fix.unknownRow', { row: arg.trim(), rows:
|
|
1694
|
+
text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ALL_ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
|
|
1504
1695
|
});
|
|
1505
1696
|
this.markDirty();
|
|
1506
1697
|
return;
|
|
@@ -1519,7 +1710,7 @@ export class SshTui {
|
|
|
1519
1710
|
const duplicates = facts.patch.readable ? planDuplicateRepair(text) : undefined;
|
|
1520
1711
|
if (duplicates !== undefined)
|
|
1521
1712
|
text = duplicates.text;
|
|
1522
|
-
const roster = planRosterRepair(text, rows);
|
|
1713
|
+
const roster = planRosterRepair(text, rows, facts.generation ?? 'legacy');
|
|
1523
1714
|
if (roster !== undefined)
|
|
1524
1715
|
text = roster.text;
|
|
1525
1716
|
const added = roster?.added ?? [];
|
|
@@ -1602,7 +1793,7 @@ export class SshTui {
|
|
|
1602
1793
|
return true;
|
|
1603
1794
|
if (this.llmRetry !== undefined)
|
|
1604
1795
|
return true;
|
|
1605
|
-
if (this.
|
|
1796
|
+
if (this.compactionRunning())
|
|
1606
1797
|
return true;
|
|
1607
1798
|
return false;
|
|
1608
1799
|
}
|
|
@@ -1902,7 +2093,7 @@ export class SshTui {
|
|
|
1902
2093
|
void this.onReattach?.();
|
|
1903
2094
|
return;
|
|
1904
2095
|
}
|
|
1905
|
-
this.write(
|
|
2096
|
+
this.write(this.enterScreenSequence());
|
|
1906
2097
|
this.forceFullPaint = true;
|
|
1907
2098
|
this.dirty = true;
|
|
1908
2099
|
// The user is back: say so, with how many times this Host has been
|
|
@@ -1928,7 +2119,8 @@ export class SshTui {
|
|
|
1928
2119
|
this.paintRttMs = rttMs;
|
|
1929
2120
|
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
1930
2121
|
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
|
|
1931
|
-
this.
|
|
2122
|
+
if (!this.lineMode)
|
|
2123
|
+
this.startRenderTimer();
|
|
1932
2124
|
}
|
|
1933
2125
|
this.markDirty();
|
|
1934
2126
|
}
|
|
@@ -2042,7 +2234,7 @@ export class SshTui {
|
|
|
2042
2234
|
return false;
|
|
2043
2235
|
if (this.streaming?.text)
|
|
2044
2236
|
return false;
|
|
2045
|
-
if (this.
|
|
2237
|
+
if (this.compactionRunning())
|
|
2046
2238
|
return false;
|
|
2047
2239
|
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
2048
2240
|
return false;
|
|
@@ -2061,12 +2253,25 @@ export class SshTui {
|
|
|
2061
2253
|
...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
|
|
2062
2254
|
...(liveTool !== undefined || liveSub === undefined
|
|
2063
2255
|
? {}
|
|
2064
|
-
: { toolTitle: liveSub
|
|
2256
|
+
: { toolTitle: subagentDisplayName(liveSub), toolSummary: subagentChipSummary(liveSub) }),
|
|
2065
2257
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
2066
2258
|
};
|
|
2067
2259
|
}
|
|
2068
2260
|
findSubagentRow(sessionId) {
|
|
2069
|
-
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
2261
|
+
return this.rows.findLast((row) => row.kind === 'subagent' && (row.sessionId === sessionId || row.childSessionId === sessionId));
|
|
2262
|
+
}
|
|
2263
|
+
subagentInspectId(row) {
|
|
2264
|
+
return row.childSessionId ?? row.sessionId;
|
|
2265
|
+
}
|
|
2266
|
+
/** Courtesy name for a child session; never the raw session hash. */
|
|
2267
|
+
subagentNameFor(sessionId) {
|
|
2268
|
+
const id = sessionId === undefined ? '' : String(sessionId);
|
|
2269
|
+
if (id === '' || id === String(this.agent.id))
|
|
2270
|
+
return t('approval.thisSession');
|
|
2271
|
+
const row = this.findSubagentRow(id);
|
|
2272
|
+
if (row !== undefined)
|
|
2273
|
+
return subagentDisplayName(row);
|
|
2274
|
+
return subagentCourtesyName({ sessionId: id, fallback: t('card.subagent') });
|
|
2070
2275
|
}
|
|
2071
2276
|
findLivePlanRow() {
|
|
2072
2277
|
return findLivePlanRow(this.rows);
|
|
@@ -2197,7 +2402,7 @@ export class SshTui {
|
|
|
2197
2402
|
const note = planDockNote(plan);
|
|
2198
2403
|
lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
|
|
2199
2404
|
if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
|
|
2200
|
-
const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
|
|
2405
|
+
const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color, this.terminalCaps.osc8);
|
|
2201
2406
|
const budget = Math.max(4, Math.min(12, markdown.length));
|
|
2202
2407
|
for (const line of markdown.slice(0, budget)) {
|
|
2203
2408
|
lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
|
|
@@ -2254,7 +2459,7 @@ export class SshTui {
|
|
|
2254
2459
|
return;
|
|
2255
2460
|
const body = buffer
|
|
2256
2461
|
.map(piece => (piece.emphasis
|
|
2257
|
-
?
|
|
2462
|
+
? this.styleEmphasisedPiece(line.kind, piece.text)
|
|
2258
2463
|
: this.styleLine(line.kind, piece.text)))
|
|
2259
2464
|
.join('');
|
|
2260
2465
|
rows.push(` ${body}`);
|
|
@@ -2276,9 +2481,59 @@ export class SshTui {
|
|
|
2276
2481
|
flush();
|
|
2277
2482
|
return rows.length === 0 ? [this.styleLine(line.kind, '')] : rows;
|
|
2278
2483
|
}
|
|
2484
|
+
/**
|
|
2485
|
+
* A body row whose parts carry different roles: the two columns of a
|
|
2486
|
+
* side-by-side diff.
|
|
2487
|
+
*
|
|
2488
|
+
* No single kind fits such a row — the left column is a removal, the right an
|
|
2489
|
+
* addition — so each part is painted with its own style, the gutter keeps the
|
|
2490
|
+
* row's own (neutral) one, and a word emphasis rides inside whichever column
|
|
2491
|
+
* it belongs to. The first column takes the leading indent and the last one
|
|
2492
|
+
* runs to the end of the row, so both bars are flush.
|
|
2493
|
+
*/
|
|
2494
|
+
styleColumnedLine(line, width) {
|
|
2495
|
+
const indent = 2;
|
|
2496
|
+
const padded = padToWidth(` ${line.text}`, Math.max(1, width));
|
|
2497
|
+
const cut = (at) => Math.max(0, Math.min(padded.length, at + indent));
|
|
2498
|
+
const columns = (line.columns ?? []).map((column, index, all) => ({
|
|
2499
|
+
start: index === 0 ? 0 : cut(column.start),
|
|
2500
|
+
end: index === all.length - 1 ? padded.length : cut(column.end),
|
|
2501
|
+
kind: column.kind,
|
|
2502
|
+
}));
|
|
2503
|
+
const cuts = new Set([0, padded.length]);
|
|
2504
|
+
for (const span of line.spans ?? []) {
|
|
2505
|
+
cuts.add(cut(span.start));
|
|
2506
|
+
cuts.add(cut(span.end));
|
|
2507
|
+
}
|
|
2508
|
+
for (const column of line.columns ?? []) {
|
|
2509
|
+
cuts.add(cut(column.start));
|
|
2510
|
+
cuts.add(cut(column.end));
|
|
2511
|
+
}
|
|
2512
|
+
const ordered = [...cuts].sort((left, right) => left - right);
|
|
2513
|
+
const pieces = [];
|
|
2514
|
+
for (let at = 0; at + 1 < ordered.length; at += 1) {
|
|
2515
|
+
const from = ordered[at] ?? 0;
|
|
2516
|
+
const to = ordered[at + 1] ?? from;
|
|
2517
|
+
if (to <= from)
|
|
2518
|
+
continue;
|
|
2519
|
+
const column = columns.find(item => item.start <= from && item.end >= to);
|
|
2520
|
+
const emphasis = (line.spans ?? []).some(span => cut(span.start) <= from && cut(span.end) >= to);
|
|
2521
|
+
const text = padded.slice(from, to);
|
|
2522
|
+
const kind = column?.kind ?? line.kind;
|
|
2523
|
+
pieces.push(emphasis ? this.styleEmphasisedPiece(kind, text) : this.styleLine(kind, text));
|
|
2524
|
+
}
|
|
2525
|
+
return pieces.length === 0 ? [this.styleLine(line.kind, '')] : [pieces.join('')];
|
|
2526
|
+
}
|
|
2279
2527
|
paintToolBodyLine(addDisplay, row, line, width) {
|
|
2280
2528
|
const inner = Math.max(1, width - 2);
|
|
2281
2529
|
const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
|
|
2530
|
+
// A line that holds two roles (the columns of a side-by-side diff) is
|
|
2531
|
+
// painted part by part; its emphasis rides inside whichever part it is in.
|
|
2532
|
+
if (line.columns !== undefined && line.columns.length > 0) {
|
|
2533
|
+
for (const painted of this.styleColumnedLine(line, width))
|
|
2534
|
+
addDisplay(painted, row);
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2282
2537
|
// An emphasised line is styled in parts: the escapes wrap *styled* pieces,
|
|
2283
2538
|
// because the painter's sanitiser strips any escape that reaches it inside
|
|
2284
2539
|
// the text — which is how a literal `[7m` once appeared on screen.
|
|
@@ -2312,7 +2567,7 @@ export class SshTui {
|
|
|
2312
2567
|
const dialog = this.dialog;
|
|
2313
2568
|
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
2314
2569
|
return;
|
|
2315
|
-
const header = this.styleLine('system', truncateToWidth(
|
|
2570
|
+
const header = this.styleLine('system', truncateToWidth(dialog.title, width));
|
|
2316
2571
|
const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
|
|
2317
2572
|
const divider = this.styleLine('system', repeatToWidth('─', width));
|
|
2318
2573
|
const bodyBudget = Math.max(1, height - 4);
|
|
@@ -2323,18 +2578,19 @@ export class SshTui {
|
|
|
2323
2578
|
for (const wrapped of wrap(line.text, inner)) {
|
|
2324
2579
|
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2325
2580
|
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));
|
|
2581
|
+
const styled = this.styleLine(kind === 'subagent' ? 'subagent-header' : kind, body);
|
|
2582
|
+
rendered.push(this.markSearchRow(styled, wrapped));
|
|
2336
2583
|
}
|
|
2337
2584
|
}
|
|
2585
|
+
// `/find` reported a hit inside this body, so put it on screen: the overlay
|
|
2586
|
+
// used to open at the top and leave the reader scrolling for it. Done once,
|
|
2587
|
+
// so PgDn afterwards keeps the reader's own position.
|
|
2588
|
+
if (dialog.searchRevealed !== true && this.searchNeedle !== '') {
|
|
2589
|
+
const at = rendered.findIndex(row => searchContains(stripAnsi(row), this.searchNeedle));
|
|
2590
|
+
if (at >= 0)
|
|
2591
|
+
dialog.offset = Math.max(0, at - 1);
|
|
2592
|
+
dialog.searchRevealed = true;
|
|
2593
|
+
}
|
|
2338
2594
|
const maxOffset = Math.max(0, rendered.length - bodyBudget);
|
|
2339
2595
|
if (dialog.offset > maxOffset)
|
|
2340
2596
|
dialog.offset = maxOffset;
|
|
@@ -2369,15 +2625,67 @@ export class SshTui {
|
|
|
2369
2625
|
this.lastChromeStart = 0;
|
|
2370
2626
|
this.lastTranscriptStart = -1;
|
|
2371
2627
|
}
|
|
2628
|
+
/**
|
|
2629
|
+
* Reverse-video (or a `»` in monochrome) on the overlay row that holds the
|
|
2630
|
+
* current `/find` needle, matching what the transcript does for the same hit.
|
|
2631
|
+
*/
|
|
2632
|
+
markSearchRow(styled, plain) {
|
|
2633
|
+
const needle = this.searchNeedle;
|
|
2634
|
+
if (needle === '' || !searchContains(plain, needle))
|
|
2635
|
+
return styled;
|
|
2636
|
+
return this.color ? highlightAnsiNeedle(styled, needle) : `» ${styled}`;
|
|
2637
|
+
}
|
|
2372
2638
|
openToolInspect(row) {
|
|
2373
2639
|
const lines = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
|
|
2640
|
+
const title = t('tool.inspectTitle', { title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}` });
|
|
2641
|
+
if (this.echoInspectToLog(title, lines))
|
|
2642
|
+
return;
|
|
2643
|
+
this.openDialog({
|
|
2644
|
+
kind: 'inspect',
|
|
2645
|
+
title,
|
|
2646
|
+
lines,
|
|
2647
|
+
offset: 0,
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
openSubagentInspect(row) {
|
|
2651
|
+
const dialog = this.dialog;
|
|
2652
|
+
if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
|
|
2653
|
+
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
2654
|
+
dialog.lines = subagentInspectLines(row);
|
|
2655
|
+
this.markDirty();
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
const title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
2659
|
+
const lines = subagentInspectLines(row);
|
|
2660
|
+
if (this.echoInspectToLog(title, lines))
|
|
2661
|
+
return;
|
|
2374
2662
|
this.openDialog({
|
|
2375
2663
|
kind: 'inspect',
|
|
2376
|
-
title
|
|
2664
|
+
title,
|
|
2377
2665
|
lines,
|
|
2378
2666
|
offset: 0,
|
|
2667
|
+
subagentSessionId: this.subagentInspectId(row),
|
|
2379
2668
|
});
|
|
2380
2669
|
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Line mode has no framed scroller, so an inspect body goes into the log.
|
|
2672
|
+
*
|
|
2673
|
+
* Opening the modal there printed nothing and then swallowed the next Enter:
|
|
2674
|
+
* an invisible dialog. The log is the only detail surface line mode has, and
|
|
2675
|
+
* writing it keeps the input free.
|
|
2676
|
+
*/
|
|
2677
|
+
echoInspectToLog(title, lines) {
|
|
2678
|
+
if (!this.lineMode)
|
|
2679
|
+
return false;
|
|
2680
|
+
const shown = lines.slice(0, LINE_MODE_INSPECT_LINES);
|
|
2681
|
+
const body = [title, ...shown.map(line => line.text)];
|
|
2682
|
+
if (lines.length > shown.length) {
|
|
2683
|
+
body.push(t('tool.bodyMoreLines', { count: lines.length - shown.length }));
|
|
2684
|
+
}
|
|
2685
|
+
this.pushRow({ kind: 'system', text: body.join('\n') });
|
|
2686
|
+
this.markDirty();
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2381
2689
|
closeInspect() {
|
|
2382
2690
|
if (this.dialog?.kind !== 'inspect')
|
|
2383
2691
|
return;
|
|
@@ -2395,12 +2703,12 @@ export class SshTui {
|
|
|
2395
2703
|
if (!row.expanded) {
|
|
2396
2704
|
const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
|
|
2397
2705
|
const styled = paint(collapsed);
|
|
2398
|
-
addDisplay(
|
|
2706
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2399
2707
|
return;
|
|
2400
2708
|
}
|
|
2401
2709
|
for (const wrapped of wrap(plain, width)) {
|
|
2402
2710
|
const styled = paint(wrapped);
|
|
2403
|
-
addDisplay(
|
|
2711
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2404
2712
|
}
|
|
2405
2713
|
}
|
|
2406
2714
|
/** Move the expand/collapse focus among reasoning and tool rows. */
|
|
@@ -2434,13 +2742,31 @@ export class SshTui {
|
|
|
2434
2742
|
return;
|
|
2435
2743
|
this.toggleCard(target);
|
|
2436
2744
|
}
|
|
2745
|
+
/**
|
|
2746
|
+
* Whether an inline tool body would be cut short.
|
|
2747
|
+
*
|
|
2748
|
+
* Two things stop a body short: it cannot fit the workspace, or the measured
|
|
2749
|
+
* link is slow enough that the paint budget trims it. Both leave the reader
|
|
2750
|
+
* looking at part of a diff under a header that counts all of it, and both
|
|
2751
|
+
* are what the overlay is for — so both answers are the overlay, and the
|
|
2752
|
+
* card's own `Enter 全览` hint stays true.
|
|
2753
|
+
*/
|
|
2754
|
+
toolBodyTruncated(target, width, height) {
|
|
2755
|
+
const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
|
|
2756
|
+
if (body.length > toolBodyLineLimit(linkQualityOf(this.paintLink, this.paintRttMs)))
|
|
2757
|
+
return true;
|
|
2758
|
+
return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
|
|
2759
|
+
}
|
|
2437
2760
|
toggleCard(target) {
|
|
2438
|
-
if (target.kind === '
|
|
2761
|
+
if (target.kind === 'subagent') {
|
|
2762
|
+
this.focusedRow = target;
|
|
2763
|
+
this.openSubagentInspect(target);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
if (target.kind === 'tool') {
|
|
2439
2767
|
const width = Math.max(10, this.screenColumns());
|
|
2440
2768
|
const height = Math.max(6, this.screenRows());
|
|
2441
|
-
|
|
2442
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2443
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2769
|
+
if (this.toolBodyTruncated(target, width, height)) {
|
|
2444
2770
|
this.focusedRow = target;
|
|
2445
2771
|
this.openToolInspect(target);
|
|
2446
2772
|
return;
|
|
@@ -2467,6 +2793,8 @@ export class SshTui {
|
|
|
2467
2793
|
const height = Math.max(6, this.screenRows());
|
|
2468
2794
|
const workspace = this.workspaceRowsFor(width, height);
|
|
2469
2795
|
for (const row of rows) {
|
|
2796
|
+
if (row.kind === 'subagent')
|
|
2797
|
+
continue;
|
|
2470
2798
|
if (row.kind === 'tool') {
|
|
2471
2799
|
const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
|
|
2472
2800
|
if (!toolBodyFitsWorkspace(bodyRows, workspace))
|
|
@@ -2519,12 +2847,15 @@ export class SshTui {
|
|
|
2519
2847
|
revealRow(row) {
|
|
2520
2848
|
if (row === undefined)
|
|
2521
2849
|
return;
|
|
2850
|
+
if (row.kind === 'subagent') {
|
|
2851
|
+
this.focusedRow = row;
|
|
2852
|
+
this.openSubagentInspect(row);
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2522
2855
|
if (row.kind === 'tool') {
|
|
2523
2856
|
const width = Math.max(10, this.screenColumns());
|
|
2524
2857
|
const height = Math.max(6, this.screenRows());
|
|
2525
|
-
|
|
2526
|
-
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
2527
|
-
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
2858
|
+
if (this.toolBodyTruncated(row, width, height)) {
|
|
2528
2859
|
this.focusedRow = row;
|
|
2529
2860
|
this.openToolInspect(row);
|
|
2530
2861
|
return;
|
|
@@ -2640,7 +2971,7 @@ export class SshTui {
|
|
|
2640
2971
|
};
|
|
2641
2972
|
const pushRow = (kind, text, ref) => {
|
|
2642
2973
|
if (kind === 'assistant') {
|
|
2643
|
-
for (const line of renderMarkdownLines(text, width, this.color)) {
|
|
2974
|
+
for (const line of renderMarkdownLines(text, width, this.color, this.terminalCaps.osc8)) {
|
|
2644
2975
|
addDisplay(line, ref);
|
|
2645
2976
|
}
|
|
2646
2977
|
return;
|
|
@@ -2696,7 +3027,7 @@ export class SshTui {
|
|
|
2696
3027
|
const header = t('reason.done', { marker, lines }) + (row.expanded ? '' : t('card.expand'));
|
|
2697
3028
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2698
3029
|
const styled = this.styleLine('reasoning', line);
|
|
2699
|
-
addDisplay(
|
|
3030
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2700
3031
|
if (row.expanded) {
|
|
2701
3032
|
for (const wrapped of wrap(row.text, width)) {
|
|
2702
3033
|
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
@@ -2727,13 +3058,13 @@ export class SshTui {
|
|
|
2727
3058
|
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
2728
3059
|
const styled = headerSegments.length === 0
|
|
2729
3060
|
? collapsed
|
|
2730
|
-
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
2731
|
-
addDisplay(
|
|
3061
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
3062
|
+
addDisplay(this.selectLine(styled, focused), row);
|
|
2732
3063
|
continue;
|
|
2733
3064
|
}
|
|
2734
3065
|
const expandedHeaderLines = headerSegments.length === 0
|
|
2735
3066
|
? wrap(header.plain, width)
|
|
2736
|
-
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
3067
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
|
|
2737
3068
|
for (const wrapped of expandedHeaderLines) {
|
|
2738
3069
|
addDisplay(wrapped, row);
|
|
2739
3070
|
}
|
|
@@ -2752,46 +3083,24 @@ export class SshTui {
|
|
|
2752
3083
|
}
|
|
2753
3084
|
if (row.kind === 'subagent') {
|
|
2754
3085
|
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
|
-
}
|
|
3086
|
+
const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
|
|
3087
|
+
const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
|
|
3088
|
+
const header = buildSubagentHeader({
|
|
3089
|
+
focused: this.focusedRow === row,
|
|
3090
|
+
title: subagentDisplayName(row),
|
|
3091
|
+
status: row.status,
|
|
3092
|
+
elapsedLabel,
|
|
3093
|
+
summary: subagentChipSummary(row),
|
|
3094
|
+
spinner: running ? ` ${this.spinnerFrame()}` : '',
|
|
3095
|
+
inspectHint: t('card.inspect'),
|
|
3096
|
+
foreign: subagentProviderDiffers(this.currentProviderId(), row.modelProvider),
|
|
3097
|
+
});
|
|
3098
|
+
const headerSegments = this.color ? header.segments : [];
|
|
3099
|
+
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
3100
|
+
const styled = headerSegments.length === 0
|
|
3101
|
+
? this.styleLine('subagent-header', collapsed)
|
|
3102
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
|
|
3103
|
+
addDisplay(this.selectLine(styled, this.focusedRow === row), row);
|
|
2795
3104
|
continue;
|
|
2796
3105
|
}
|
|
2797
3106
|
if (row.kind === 'plan') {
|
|
@@ -2805,7 +3114,7 @@ export class SshTui {
|
|
|
2805
3114
|
if (row.expanded) {
|
|
2806
3115
|
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
|
|
2807
3116
|
if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
|
|
2808
|
-
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
|
|
3117
|
+
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 8)) {
|
|
2809
3118
|
addDisplay(` ${line}`, row);
|
|
2810
3119
|
}
|
|
2811
3120
|
}
|
|
@@ -2843,7 +3152,7 @@ export class SshTui {
|
|
|
2843
3152
|
}
|
|
2844
3153
|
if (row.detail !== undefined && row.detail !== '') {
|
|
2845
3154
|
if (row.intent === 'plan-review') {
|
|
2846
|
-
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
|
|
3155
|
+
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8)) {
|
|
2847
3156
|
addDisplay(` ${line}`, row);
|
|
2848
3157
|
}
|
|
2849
3158
|
}
|
|
@@ -2922,7 +3231,7 @@ export class SshTui {
|
|
|
2922
3231
|
+ (elapsed > 0 ? t('reason.elapsed', { seconds: elapsed }) : '');
|
|
2923
3232
|
const line = `${focused ? '▶ ' : ' '}${header}`;
|
|
2924
3233
|
const styled = this.styleLine('reasoning', line);
|
|
2925
|
-
addDisplay(
|
|
3234
|
+
addDisplay(this.selectLine(styled, focused), block);
|
|
2926
3235
|
if (block.expanded) {
|
|
2927
3236
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
2928
3237
|
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
@@ -3041,8 +3350,50 @@ export class SshTui {
|
|
|
3041
3350
|
addDialog(t('onboard.ctrlF'));
|
|
3042
3351
|
if (ob.providerType === 'catalog')
|
|
3043
3352
|
addDialog(t('onboard.modelsCatalogHint'));
|
|
3353
|
+
addDialog(t('onboard.modelsPickHint'));
|
|
3044
3354
|
addDialog(t('onboard.enterEsc'));
|
|
3045
3355
|
break;
|
|
3356
|
+
case 'models-pick': {
|
|
3357
|
+
const candidates = ob.modelCandidates ?? [];
|
|
3358
|
+
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3359
|
+
addDialog(t('onboard.modelsPick', { count: candidates.length }));
|
|
3360
|
+
const start = pickerWindowStart(ob.modelCursor ?? 0, candidates.length);
|
|
3361
|
+
const end = Math.min(candidates.length, start + PICKER_WINDOW);
|
|
3362
|
+
if (start > 0)
|
|
3363
|
+
addDialog(` ${t('picker.moreAbove', { count: start })}`);
|
|
3364
|
+
for (let index = start; index < end; index += 1) {
|
|
3365
|
+
const id = candidates[index];
|
|
3366
|
+
if (id === undefined)
|
|
3367
|
+
continue;
|
|
3368
|
+
const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
|
|
3369
|
+
const mark = (ob.modelChecked ?? new Set()).has(index) ? '◉' : '○';
|
|
3370
|
+
addDialog(` ${focused} ${mark} ${id}`);
|
|
3371
|
+
}
|
|
3372
|
+
if (end < candidates.length)
|
|
3373
|
+
addDialog(` ${t('picker.moreBelow', { count: candidates.length - end })}`);
|
|
3374
|
+
addDialog(t('onboard.modelsCheckHint'));
|
|
3375
|
+
break;
|
|
3376
|
+
}
|
|
3377
|
+
case 'model-default': {
|
|
3378
|
+
const checked = this.checkedOnboardingModels(ob);
|
|
3379
|
+
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3380
|
+
addDialog(t('onboard.defaultModelPick'));
|
|
3381
|
+
const start = pickerWindowStart(ob.modelCursor ?? 0, checked.length);
|
|
3382
|
+
const end = Math.min(checked.length, start + PICKER_WINDOW);
|
|
3383
|
+
if (start > 0)
|
|
3384
|
+
addDialog(` ${t('picker.moreAbove', { count: start })}`);
|
|
3385
|
+
for (let index = start; index < end; index += 1) {
|
|
3386
|
+
const id = checked[index];
|
|
3387
|
+
if (id === undefined)
|
|
3388
|
+
continue;
|
|
3389
|
+
const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
|
|
3390
|
+
addDialog(` ${focused} ○ ${id}`);
|
|
3391
|
+
}
|
|
3392
|
+
if (end < checked.length)
|
|
3393
|
+
addDialog(` ${t('picker.moreBelow', { count: checked.length - end })}`);
|
|
3394
|
+
addDialog(t('onboard.pickHint'));
|
|
3395
|
+
break;
|
|
3396
|
+
}
|
|
3046
3397
|
case 'context':
|
|
3047
3398
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
3048
3399
|
addDialog(t('onboard.contextPrompt'));
|
|
@@ -3061,6 +3412,9 @@ export class SshTui {
|
|
|
3061
3412
|
api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
|
|
3062
3413
|
}));
|
|
3063
3414
|
addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
|
|
3415
|
+
if (ob.defaultModel !== undefined) {
|
|
3416
|
+
addDialog(t('onboard.confirmDefaultModel', { model: ob.defaultModel }));
|
|
3417
|
+
}
|
|
3064
3418
|
if (ob.routeContextWindow !== undefined) {
|
|
3065
3419
|
addDialog(t('onboard.confirmContext', { value: String(ob.routeContextWindow) }));
|
|
3066
3420
|
}
|
|
@@ -3081,7 +3435,7 @@ export class SshTui {
|
|
|
3081
3435
|
addDialog(t('dialog.planReview', { index: d.index + 1, total: d.total }) + (d.question.header === undefined ? '' : ` · ${d.question.header}`));
|
|
3082
3436
|
addDialog(d.question.question);
|
|
3083
3437
|
if (d.question.detail !== undefined && d.question.detail !== '') {
|
|
3084
|
-
for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
|
|
3438
|
+
for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 16)) {
|
|
3085
3439
|
dialogLines.push(this.styleLine('assistant', line));
|
|
3086
3440
|
}
|
|
3087
3441
|
}
|
|
@@ -3158,8 +3512,8 @@ export class SshTui {
|
|
|
3158
3512
|
continue;
|
|
3159
3513
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
3160
3514
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
3161
|
-
suggestionLines.push(index === this.suggestionIndex
|
|
3162
|
-
?
|
|
3515
|
+
suggestionLines.push(index === this.suggestionIndex
|
|
3516
|
+
? this.selectLine(fitLine(line), true)
|
|
3163
3517
|
: this.styleLine('system', fitLine(line)));
|
|
3164
3518
|
}
|
|
3165
3519
|
if (suggestionEnd < this.commandSuggestions.length) {
|
|
@@ -3265,7 +3619,7 @@ export class SshTui {
|
|
|
3265
3619
|
? formatFooterBalance(this.balanceSnapshot)
|
|
3266
3620
|
: undefined;
|
|
3267
3621
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
3268
|
-
const compacting = this.
|
|
3622
|
+
const compacting = this.compactionRunning();
|
|
3269
3623
|
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
3270
3624
|
const sub = this.subagentSelection.current;
|
|
3271
3625
|
const footer = {
|
|
@@ -3289,7 +3643,7 @@ export class SshTui {
|
|
|
3289
3643
|
provider,
|
|
3290
3644
|
parentModel,
|
|
3291
3645
|
subModel: sub.model,
|
|
3292
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
3646
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
3293
3647
|
...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
|
|
3294
3648
|
// Quota and context pressure stay on the identity line, where they have
|
|
3295
3649
|
// always been: B-1 moved them one row up into the strip, and a user
|
|
@@ -3326,17 +3680,24 @@ export class SshTui {
|
|
|
3326
3680
|
: activity.text;
|
|
3327
3681
|
const identity = footerIdentityParts(footer);
|
|
3328
3682
|
const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3683
|
+
// Accents on an otherwise muted line: the context ring, then the `sub:`
|
|
3684
|
+
// chip. Each reset reopens mute (`90`), the same way the pre-strip footer
|
|
3685
|
+
// did. `styleLine` sanitises first, so the accents are spliced in after.
|
|
3686
|
+
let statusLine = this.styleLine('system', statusText);
|
|
3687
|
+
if (this.color && this.contextPressure !== undefined) {
|
|
3688
|
+
const ring = formatContextPressureRing(this.contextPressure.percent);
|
|
3689
|
+
statusLine = statusLine.replace(ring, `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${ring}\x1b[0m\x1b[90m`);
|
|
3690
|
+
}
|
|
3691
|
+
if (this.color) {
|
|
3692
|
+
const chip = identity.find(part => part.startsWith('sub:')) ?? '';
|
|
3693
|
+
statusLine = paintFooterSubagentChip(statusLine, chip, footerSubagentForeign(footer), this.mutedSgr() || '90', this.colorDepth);
|
|
3694
|
+
}
|
|
3334
3695
|
// The strip: one loss order for the groups a narrow terminal can do without.
|
|
3335
3696
|
// Health leads because it is the only group that reports a broken install;
|
|
3336
3697
|
// the session counters go before the operational signals. Quota and context
|
|
3337
3698
|
// pressure are deliberately not here — they are on the identity line.
|
|
3338
3699
|
const strip = [];
|
|
3339
|
-
const health = footerHealthChip(this.rosterMissing, this.color);
|
|
3700
|
+
const health = footerHealthChip(this.rosterMissing, this.color, this.settingsGeneration === 'forms' ? 'agent-plane' : 'roster');
|
|
3340
3701
|
// Muted like the identity line, accent excepted: the pre-strip footer
|
|
3341
3702
|
// painted the counters dim and only the ⚠'s own glyph yellow.
|
|
3342
3703
|
if (health !== undefined)
|
|
@@ -3480,7 +3841,7 @@ export class SshTui {
|
|
|
3480
3841
|
}
|
|
3481
3842
|
currentSelectionLabel() {
|
|
3482
3843
|
const current = this.selectionRef?.current;
|
|
3483
|
-
const provider = this.currentProviderId();
|
|
3844
|
+
const provider = this.displayProviderId(this.currentProviderId());
|
|
3484
3845
|
const model = current?.model ?? this.agent.options.model ?? 'unknown';
|
|
3485
3846
|
const effort = current?.reasoningEffort;
|
|
3486
3847
|
const kind = describeProviderRoute(provider).short;
|
|
@@ -3492,7 +3853,11 @@ export class SshTui {
|
|
|
3492
3853
|
}
|
|
3493
3854
|
/** Refresh the terminal window title (throttled while running). */
|
|
3494
3855
|
updateTerminalTitle() {
|
|
3495
|
-
if (this.exiting)
|
|
3856
|
+
if (this.exiting || this.lineMode)
|
|
3857
|
+
return;
|
|
3858
|
+
// A console that has no title to set (the Linux virtual console) would take
|
|
3859
|
+
// the escape as noise; the matrix says so, so honour it.
|
|
3860
|
+
if (!this.terminalCaps.title)
|
|
3496
3861
|
return;
|
|
3497
3862
|
const now = Date.now();
|
|
3498
3863
|
// Completion wins over a still-running agent status: the turn/end event
|
|
@@ -3503,16 +3868,20 @@ export class SshTui {
|
|
|
3503
3868
|
this.write(t('title.done', { suffix: titleSuffix }));
|
|
3504
3869
|
return;
|
|
3505
3870
|
}
|
|
3506
|
-
|
|
3871
|
+
// A compaction is work in progress even when it runs between turns (the
|
|
3872
|
+
// idle auto-compact), so it keeps the spinner title the way a running turn
|
|
3873
|
+
// does — the same rule the footer's activity chip follows.
|
|
3874
|
+
const compacting = this.compactionRunning();
|
|
3875
|
+
if (this.agent.status === 'running' || compacting) {
|
|
3507
3876
|
if (now - this.lastTitleUpdateAt < 800)
|
|
3508
3877
|
return;
|
|
3509
3878
|
this.lastTitleUpdateAt = now;
|
|
3510
3879
|
const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
|
|
3511
|
-
let detail = t('title.running');
|
|
3880
|
+
let detail = compacting ? t('title.compacting') : t('title.running');
|
|
3512
3881
|
if (this.dialog?.kind === 'questions') {
|
|
3513
3882
|
detail = planReviewOf(this.dialog.question) ? t('question.planTitle') : t('title.waitAnswer');
|
|
3514
3883
|
}
|
|
3515
|
-
else if (
|
|
3884
|
+
else if (compacting) {
|
|
3516
3885
|
detail = t('title.compacting');
|
|
3517
3886
|
}
|
|
3518
3887
|
else if (this.activeSubagents.size > 0) {
|
|
@@ -3538,6 +3907,8 @@ export class SshTui {
|
|
|
3538
3907
|
}
|
|
3539
3908
|
/** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
|
|
3540
3909
|
playCompletionSignal() {
|
|
3910
|
+
if (this.lineMode)
|
|
3911
|
+
return;
|
|
3541
3912
|
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
3542
3913
|
if (disabled)
|
|
3543
3914
|
return;
|
|
@@ -3596,13 +3967,7 @@ export class SshTui {
|
|
|
3596
3967
|
if (service === undefined) {
|
|
3597
3968
|
this.pushRow({
|
|
3598
3969
|
kind: 'error',
|
|
3599
|
-
text:
|
|
3600
|
-
t('preset.missingService'),
|
|
3601
|
-
t('mode.missingServiceHint', {
|
|
3602
|
-
profile: profileFromArgv(),
|
|
3603
|
-
patch: rosterPatchPath(resolveDshHome(), profileFromArgv()),
|
|
3604
|
-
}),
|
|
3605
|
-
].join('\n'),
|
|
3970
|
+
text: this.missingPresetService(t('preset.missingService')),
|
|
3606
3971
|
});
|
|
3607
3972
|
this.markDirty();
|
|
3608
3973
|
return;
|
|
@@ -3782,9 +4147,11 @@ export class SshTui {
|
|
|
3782
4147
|
}
|
|
3783
4148
|
const lines = [t('preset.showTitle', { id: preset.id, name: preset.name ?? preset.id })];
|
|
3784
4149
|
lines.push(t('preset.showMeta', {
|
|
3785
|
-
trust: preset.trust === 'user'
|
|
4150
|
+
trust: preset.trust === 'user'
|
|
4151
|
+
? t('preset.trustUser')
|
|
4152
|
+
: preset.trust === undefined ? t('preset.trustManaged') : t('preset.trustSystem'),
|
|
3786
4153
|
order: preset.order === undefined ? '—' : String(preset.order),
|
|
3787
|
-
directory: presetDirectory(preset),
|
|
4154
|
+
directory: presetDirectory(preset) ?? t('preset.directoryManaged'),
|
|
3788
4155
|
}));
|
|
3789
4156
|
if (preset.description !== undefined)
|
|
3790
4157
|
lines.push(t('preset.showDescription', { description: preset.description }));
|
|
@@ -3849,7 +4216,7 @@ export class SshTui {
|
|
|
3849
4216
|
from: plan.from,
|
|
3850
4217
|
id: plan.id,
|
|
3851
4218
|
name: plan.name === undefined ? '' : ` · ${plan.name}`,
|
|
3852
|
-
directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]),
|
|
4219
|
+
directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]) ?? '',
|
|
3853
4220
|
});
|
|
3854
4221
|
});
|
|
3855
4222
|
}
|
|
@@ -3872,7 +4239,8 @@ export class SshTui {
|
|
|
3872
4239
|
}
|
|
3873
4240
|
// Merge with what the preset publishes now, so editing one field never
|
|
3874
4241
|
// drops the other and `order` survives.
|
|
3875
|
-
const
|
|
4242
|
+
const directory = presetDirectory(preset);
|
|
4243
|
+
const current = directory === undefined ? {} : await readPresetMetadata(directory);
|
|
3876
4244
|
const plan = planMetadata({ preset, current, patch });
|
|
3877
4245
|
if (isRefusal(plan)) {
|
|
3878
4246
|
this.pushPresetRefusal(plan);
|
|
@@ -3915,7 +4283,7 @@ export class SshTui {
|
|
|
3915
4283
|
return;
|
|
3916
4284
|
}
|
|
3917
4285
|
const answer = await new Promise(resolve => {
|
|
3918
|
-
this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) }), resolve);
|
|
4286
|
+
this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) ?? t('preset.directoryManaged') }), resolve);
|
|
3919
4287
|
});
|
|
3920
4288
|
if (answer !== 'y') {
|
|
3921
4289
|
this.pushRow({ kind: 'system', text: t('preset.deleteCancelled') });
|
|
@@ -3999,9 +4367,10 @@ export class SshTui {
|
|
|
3999
4367
|
kind === 'todo-skipped' ? '2;33' :
|
|
4000
4368
|
kind === 'todo-pending' ? '90' :
|
|
4001
4369
|
kind === 'plan-dock' ? '38;5;180' :
|
|
4002
|
-
kind === '
|
|
4003
|
-
kind === '
|
|
4004
|
-
'
|
|
4370
|
+
kind === 'subagent-header' ? '38;5;141' :
|
|
4371
|
+
kind === 'diag' ? '2;36' :
|
|
4372
|
+
kind === 'error' ? '31' :
|
|
4373
|
+
'90';
|
|
4005
4374
|
const code = downgradeSgr(requested, this.colorDepth);
|
|
4006
4375
|
// Nothing left to paint: the text stands on its own.
|
|
4007
4376
|
if (code === '')
|
|
@@ -4009,11 +4378,45 @@ export class SshTui {
|
|
|
4009
4378
|
return `\x1b[${code}m${safe}\x1b[0m`;
|
|
4010
4379
|
}
|
|
4011
4380
|
/**
|
|
4012
|
-
*
|
|
4013
|
-
*
|
|
4014
|
-
*
|
|
4381
|
+
* One emphasised piece of a body line — the words a diff changed.
|
|
4382
|
+
*
|
|
4383
|
+
* Inside a filled diff row the mark is a lighter shade of that same fill.
|
|
4384
|
+
* Inverse video was there first, and it inverted the *fill*: a line replaced
|
|
4385
|
+
* wholesale is emphasised from end to end, so it came out a bright block
|
|
4386
|
+
* beside rows that kept the muted tone — visibly from another palette. On a
|
|
4387
|
+
* terminal with no colour left to shift, the attribute is what survives, so
|
|
4388
|
+
* the inverse video stays for that case.
|
|
4389
|
+
*/
|
|
4390
|
+
styleEmphasisedPiece(kind, text) {
|
|
4391
|
+
const requested = DIFF_EMPHASIS_SGR[kind];
|
|
4392
|
+
if (requested !== undefined && this.color) {
|
|
4393
|
+
const code = downgradeSgr(requested, this.colorDepth);
|
|
4394
|
+
if (code !== '')
|
|
4395
|
+
return `\x1b[${code}m${sanitizeTerminalText(text)}\x1b[0m`;
|
|
4396
|
+
}
|
|
4397
|
+
return `\x1b[7m${this.styleLine(kind, text)}\x1b[27m`;
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
4400
|
+
* Paint a row the reader has selected.
|
|
4401
|
+
*
|
|
4402
|
+
* The obvious `7`…`27` wrap only reaches the part before the line's first
|
|
4403
|
+
* reset, and every painted segment ends with one: a focused card kept its
|
|
4404
|
+
* marker and status dot highlighted while the title, the state word and the
|
|
4405
|
+
* summary stayed plain, so ↑/↓ gave no visible clue which card was picked —
|
|
4406
|
+
* on exactly the rows that carry a segmented header. Re-opening the attribute
|
|
4407
|
+
* after every reset covers the whole row, whatever colours sit on top of it.
|
|
4408
|
+
*/
|
|
4409
|
+
selectLine(styled, selected) {
|
|
4410
|
+
if (!selected || !this.color)
|
|
4411
|
+
return styled;
|
|
4412
|
+
return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
|
|
4413
|
+
}
|
|
4414
|
+
/**
|
|
4415
|
+
* Fold one live stream chunk into the in-progress assistant row. Hosts from
|
|
4416
|
+
* 0.1.5 on emit the chunk on `agent/assistant-stream` and never write it to
|
|
4417
|
+
* the log.
|
|
4015
4418
|
*/
|
|
4016
|
-
applyStreamChunk(streamed
|
|
4419
|
+
applyStreamChunk(streamed) {
|
|
4017
4420
|
const { chunk } = streamed;
|
|
4018
4421
|
if (isTokenDeltaChunk(chunk)) {
|
|
4019
4422
|
this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
|
|
@@ -4021,8 +4424,6 @@ export class SshTui {
|
|
|
4021
4424
|
if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
|
|
4022
4425
|
this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
|
|
4023
4426
|
}
|
|
4024
|
-
if (statsOnly)
|
|
4025
|
-
return;
|
|
4026
4427
|
if (chunk.type === 'text-delta') {
|
|
4027
4428
|
this.streaming ??= { text: '', reasoning: '' };
|
|
4028
4429
|
this.streaming.text += chunk.text ?? '';
|
|
@@ -4039,9 +4440,9 @@ export class SshTui {
|
|
|
4039
4440
|
}
|
|
4040
4441
|
}
|
|
4041
4442
|
/**
|
|
4042
|
-
* 0.1.5 live tokens arrive as process-local
|
|
4043
|
-
* frames (start / chunk / end). Chunk frames carry
|
|
4044
|
-
* `StreamChunk` the
|
|
4443
|
+
* 0.1.5 and 0.1.7 live tokens arrive as process-local
|
|
4444
|
+
* `agent/assistant-stream` frames (start / chunk / end). Chunk frames carry
|
|
4445
|
+
* the same `StreamChunk` the settled `assistant/message` embeds in `stream`.
|
|
4045
4446
|
*/
|
|
4046
4447
|
handleAssistantStream = (...args) => {
|
|
4047
4448
|
const payload = args[0];
|
|
@@ -4080,6 +4481,12 @@ export class SshTui {
|
|
|
4080
4481
|
* The parent request is left untouched (its own `/model` waterfall already
|
|
4081
4482
|
* owns the route); direct children created by tool-subagent inherit the
|
|
4082
4483
|
* parent provider unless `/submodel` stored an explicit subagent provider.
|
|
4484
|
+
*
|
|
4485
|
+
* Effort is not inherited from the parent. `next()` copies the live parent
|
|
4486
|
+
* config, so a grok-4.6 `xhigh` parent would otherwise send `xhigh` with
|
|
4487
|
+
* the light grok-4.5 child and fail with UNSUPPORTED_REASONING_EFFORT
|
|
4488
|
+
* before the child writes a closing message. An explicit `/subeffort`
|
|
4489
|
+
* still wins; otherwise the child's own adapter default is used.
|
|
4083
4490
|
*/
|
|
4084
4491
|
handleAgentRequest = async ({ agent }, next) => {
|
|
4085
4492
|
const resolved = await next();
|
|
@@ -4088,14 +4495,23 @@ export class SshTui {
|
|
|
4088
4495
|
const selection = this.subagentSelection.current;
|
|
4089
4496
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
4090
4497
|
const provider = selection.provider ?? parentProvider;
|
|
4091
|
-
|
|
4498
|
+
// A pinned provider keeps the stored model even when it is a different
|
|
4499
|
+
// family from the parent. Only an inherited (follow-parent) route is
|
|
4500
|
+
// rewritten onto a same-family default.
|
|
4501
|
+
const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
|
|
4092
4502
|
? selection.model
|
|
4093
4503
|
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
4504
|
+
const { reasoningEffort: parentEffort, ...withoutParentEffort } = resolved;
|
|
4505
|
+
// `/subeffort` wins. Otherwise keep the parent effort only when the child
|
|
4506
|
+
// is still on that same model — grok-4.6 xhigh is legal on grok-4.6 and
|
|
4507
|
+
// rejected on grok-4.5.
|
|
4508
|
+
const effort = selection.reasoningEffort
|
|
4509
|
+
?? (model === resolved.model ? parentEffort : undefined);
|
|
4094
4510
|
return {
|
|
4095
|
-
...
|
|
4511
|
+
...withoutParentEffort,
|
|
4096
4512
|
provider,
|
|
4097
4513
|
model,
|
|
4098
|
-
...(
|
|
4514
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
4099
4515
|
};
|
|
4100
4516
|
};
|
|
4101
4517
|
handleSessionEvent = (session, event) => {
|
|
@@ -4115,23 +4531,8 @@ export class SshTui {
|
|
|
4115
4531
|
return;
|
|
4116
4532
|
}
|
|
4117
4533
|
this.lastActivity = Date.now();
|
|
4118
|
-
if (this.replaying && isAssistantStreamEvent(event)) {
|
|
4119
|
-
// Replay skips the in-progress row, but durable chunks still carry the
|
|
4120
|
-
// timing the footer needs: a resumed session must keep TTFT and tok/s.
|
|
4121
|
-
const streamed = streamChunkOf(event);
|
|
4122
|
-
if (streamed !== undefined)
|
|
4123
|
-
this.applyStreamChunk(streamed, true);
|
|
4124
|
-
return;
|
|
4125
|
-
}
|
|
4126
4534
|
if (!this.replaying)
|
|
4127
4535
|
this.refreshContextPressure();
|
|
4128
|
-
const eventType = sessionEventType(event);
|
|
4129
|
-
if (eventType === 'assistant/chunk') {
|
|
4130
|
-
const streamed = streamChunkOf(event);
|
|
4131
|
-
if (streamed !== undefined)
|
|
4132
|
-
this.applyStreamChunk(streamed);
|
|
4133
|
-
return;
|
|
4134
|
-
}
|
|
4135
4536
|
switch (event.type) {
|
|
4136
4537
|
case 'user/message': {
|
|
4137
4538
|
const text = event.data.content
|
|
@@ -4189,8 +4590,7 @@ export class SshTui {
|
|
|
4189
4590
|
.join('');
|
|
4190
4591
|
// The settlement's own packed stream is authoritative: a retried step
|
|
4191
4592
|
// keeps the failed attempt's first token in the live latch, and spanning
|
|
4192
|
-
// both attempts reported a rate ~20x off.
|
|
4193
|
-
// relies on the replayed `assistant/chunk` events (or the live latch).
|
|
4593
|
+
// both attempts reported a rate ~20x off.
|
|
4194
4594
|
this.statsTracker.settleMessage({
|
|
4195
4595
|
turn: event.data.turn,
|
|
4196
4596
|
step: event.data.step,
|
|
@@ -4225,6 +4625,30 @@ export class SshTui {
|
|
|
4225
4625
|
this.statsTracker.noteToolStart(String(event.data.callId), event.time);
|
|
4226
4626
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
4227
4627
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4628
|
+
if (SUBAGENT_TOOL_NAMES.has(event.data.name)) {
|
|
4629
|
+
const task = present.summary.trim();
|
|
4630
|
+
const callId = String(event.data.callId);
|
|
4631
|
+
// Replay never gets `subagent/start` to drain this queue, so only a
|
|
4632
|
+
// live spawn waits for its child card. The pair is dropped again at
|
|
4633
|
+
// `tool/result` when the spawn never started a child (denied,
|
|
4634
|
+
// cancelled): a description left behind would name the next child.
|
|
4635
|
+
if (task !== '' && !this.replaying)
|
|
4636
|
+
this.pendingSubagentTasks.push({ task, callId });
|
|
4637
|
+
if (this.replaying && !this.rows.some(candidate => candidate.kind === 'subagent' && candidate.runId === callId)) {
|
|
4638
|
+
// Live `subagent/start` is not in the parent log. Resume still
|
|
4639
|
+
// needs a courtesy chip; a live Host already has the real card.
|
|
4640
|
+
this.pushRow(subagentRowFromSpawnTool({
|
|
4641
|
+
callId,
|
|
4642
|
+
task,
|
|
4643
|
+
modelProvider: this.currentProviderId(),
|
|
4644
|
+
status: 'running',
|
|
4645
|
+
startedAt: typeof event.time === 'number' ? event.time : Date.now(),
|
|
4646
|
+
}));
|
|
4647
|
+
}
|
|
4648
|
+
this.streaming = undefined;
|
|
4649
|
+
this.markDirty();
|
|
4650
|
+
break;
|
|
4651
|
+
}
|
|
4228
4652
|
const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
|
|
4229
4653
|
if (previous !== undefined) {
|
|
4230
4654
|
this.mergeIntoToolCard(previous, {
|
|
@@ -4267,8 +4691,30 @@ export class SshTui {
|
|
|
4267
4691
|
this.openToolCalls.delete(String(event.data.message.source.callId));
|
|
4268
4692
|
this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
|
|
4269
4693
|
const callId = String(event.data.message.source.callId);
|
|
4270
|
-
|
|
4694
|
+
// A spawn whose wrapper came back without starting a child (approval
|
|
4695
|
+
// denied, provider refused, turn cancelled) must not leave its
|
|
4696
|
+
// description queued: the next child would inherit that name.
|
|
4697
|
+
this.pendingSubagentTasks = this.pendingSubagentTasks.filter(entry => entry.callId !== callId);
|
|
4698
|
+
const spawnCard = this.rows.findLast((candidate) => candidate.kind === 'subagent' && (candidate.runId === callId || candidate.spawnCallId === callId));
|
|
4271
4699
|
const output = collectText(event.data.message.content);
|
|
4700
|
+
if (spawnCard !== undefined) {
|
|
4701
|
+
// A live child already owns this spawn: the parent tool/result is
|
|
4702
|
+
// just the wrapper settling. Do not stamp a 0s twin chip complete.
|
|
4703
|
+
if (spawnCard.childSessionId !== undefined) {
|
|
4704
|
+
this.markDirty();
|
|
4705
|
+
break;
|
|
4706
|
+
}
|
|
4707
|
+
const failed = event.data.error !== undefined || toolResultFailed(event.data.message);
|
|
4708
|
+
spawnCard.status = failed ? 'error' : 'ok';
|
|
4709
|
+
spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
|
|
4710
|
+
if (output !== '') {
|
|
4711
|
+
spawnCard.lastActivity = clipSubagentActivity(output, 80);
|
|
4712
|
+
appendSubagentLog(spawnCard, { kind: failed ? 'result' : 'assistant', text: clipSubagentActivity(output, 80) });
|
|
4713
|
+
}
|
|
4714
|
+
this.markDirty();
|
|
4715
|
+
break;
|
|
4716
|
+
}
|
|
4717
|
+
const row = this.findToolRowByCallId(callId);
|
|
4272
4718
|
if (row !== undefined) {
|
|
4273
4719
|
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
4274
4720
|
if (metaDiffs !== null) {
|
|
@@ -4289,7 +4735,7 @@ export class SshTui {
|
|
|
4289
4735
|
row.output = output;
|
|
4290
4736
|
}
|
|
4291
4737
|
const failed = event.data.error !== undefined
|
|
4292
|
-
|| event.data.message
|
|
4738
|
+
|| toolResultFailed(event.data.message)
|
|
4293
4739
|
|| (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
|
|
4294
4740
|
row.status = failed ? 'error' : 'ok';
|
|
4295
4741
|
if (READ_TOOL_NAMES.has(row.name)) {
|
|
@@ -4303,11 +4749,14 @@ export class SshTui {
|
|
|
4303
4749
|
?? (typeof sourceName === 'string' ? sourceName : '');
|
|
4304
4750
|
if (recordedName !== '' && HIDDEN_TOOL_NAMES.has(recordedName))
|
|
4305
4751
|
break;
|
|
4752
|
+
// A spawn is drawn as the child's chip, never as a `subagent` tool
|
|
4753
|
+
// card: without a start there is still no card to settle, and a
|
|
4754
|
+
// second card beside the chip is exactly the duplicate to avoid.
|
|
4755
|
+
if (SUBAGENT_TOOL_NAMES.has(recordedName))
|
|
4756
|
+
break;
|
|
4306
4757
|
// Never title a card with the call id (`call-<uuid>`). Prefer the
|
|
4307
4758
|
// recorded tool name; fall back to a generic tool card.
|
|
4308
|
-
const toolName = recordedName
|
|
4309
|
-
? 'tool'
|
|
4310
|
-
: recordedName;
|
|
4759
|
+
const toolName = displayToolName(recordedName);
|
|
4311
4760
|
const present = presentToolCall(toolName, '');
|
|
4312
4761
|
this.pushRow({
|
|
4313
4762
|
kind: 'tool',
|
|
@@ -4356,6 +4805,9 @@ export class SshTui {
|
|
|
4356
4805
|
this.stalledWarningShown = false;
|
|
4357
4806
|
this.llmRetry = undefined;
|
|
4358
4807
|
this.status = `turn ${event.data.turn} running`;
|
|
4808
|
+
// A turn is about to spend on this route: make sure the session's record
|
|
4809
|
+
// names it, whatever changed it (a preset, a settings edit, a resume).
|
|
4810
|
+
this.noteSessionRoute();
|
|
4359
4811
|
this.markDirty();
|
|
4360
4812
|
break;
|
|
4361
4813
|
case 'turn/end': {
|
|
@@ -4364,6 +4816,10 @@ export class SshTui {
|
|
|
4364
4816
|
this.toolCallNames.clear();
|
|
4365
4817
|
this.statsTracker.noteTurnEnd();
|
|
4366
4818
|
this.stalledWarningShown = false;
|
|
4819
|
+
// A retry belongs to the turn that scheduled it: once the turn closes,
|
|
4820
|
+
// the chip must not outlive it (a failed retry would otherwise leave
|
|
4821
|
+
// "重试 n/m" on an idle footer until the next turn).
|
|
4822
|
+
this.llmRetry = undefined;
|
|
4367
4823
|
this.pendingMessages.clear();
|
|
4368
4824
|
// Aborted/errored turns may close without an assembled
|
|
4369
4825
|
// assistant/message; never leave a half-streamed thinking block behind.
|
|
@@ -4538,6 +4994,12 @@ export class SshTui {
|
|
|
4538
4994
|
if (this.llmRetry !== undefined) {
|
|
4539
4995
|
this.pushRow({ kind: 'system', text: t('retry.started', { retry: this.llmRetry.retry }) });
|
|
4540
4996
|
}
|
|
4997
|
+
// The backoff is over and the retried request is in flight: the footer
|
|
4998
|
+
// must go back to the ordinary running/waiting states instead of holding
|
|
4999
|
+
// "retry n/m" until the next turn starts (which could be minutes later,
|
|
5000
|
+
// or never — and a compaction ending in between fell back to this stale
|
|
5001
|
+
// chip rather than to 运行中).
|
|
5002
|
+
this.llmRetry = undefined;
|
|
4541
5003
|
this.markDirty();
|
|
4542
5004
|
return;
|
|
4543
5005
|
}
|
|
@@ -4560,7 +5022,54 @@ export class SshTui {
|
|
|
4560
5022
|
if (named !== undefined)
|
|
4561
5023
|
return named;
|
|
4562
5024
|
}
|
|
4563
|
-
|
|
5025
|
+
// An event whose id matches no card still belongs to the compaction in
|
|
5026
|
+
// flight: the Host runs at most one at a time, so the newest running card is
|
|
5027
|
+
// the only candidate. Without this a mismatched (or absent) id left the row
|
|
5028
|
+
// running forever — the same stuck state an abandoned Host produces.
|
|
5029
|
+
return this.runningCompactions().at(-1);
|
|
5030
|
+
}
|
|
5031
|
+
/**
|
|
5032
|
+
* Compaction cards that can still be in flight: not finished, and within the
|
|
5033
|
+
* window a real compaction could take. A stale card is treated as not running
|
|
5034
|
+
* so it cannot hold the footer, the spinner, or `/compact` hostage.
|
|
5035
|
+
*/
|
|
5036
|
+
runningCompactions(now = Date.now()) {
|
|
5037
|
+
return this.rows.filter((row) => row.kind === 'compaction'
|
|
5038
|
+
&& row.status === 'running'
|
|
5039
|
+
&& now - row.startedAt < COMPACTION_STALE_MS);
|
|
5040
|
+
}
|
|
5041
|
+
/** Whether a compaction is genuinely in flight right now. */
|
|
5042
|
+
compactionRunning() {
|
|
5043
|
+
return this.runningCompactions().length > 0;
|
|
5044
|
+
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Close compaction cards whose Host is gone.
|
|
5047
|
+
*
|
|
5048
|
+
* `compaction/start` is written before the work and `compaction/end` after it,
|
|
5049
|
+
* so a Host that exits in between leaves an open start in the durable log.
|
|
5050
|
+
* Replaying that log faithfully used to recreate a card stuck on "running"
|
|
5051
|
+
* forever: the footer said 压缩中, the spinner kept animating, and every later
|
|
5052
|
+
* `/compact` was refused as "already compacting". After a full replay nothing
|
|
5053
|
+
* more can arrive for those starts, so they are settled here.
|
|
5054
|
+
*
|
|
5055
|
+
* `staleOnly` keeps the same sweep usable while the session is live, where a
|
|
5056
|
+
* compaction that is merely slow must not be closed under it.
|
|
5057
|
+
*/
|
|
5058
|
+
settleUnfinishedCompactions(options = {}) {
|
|
5059
|
+
const now = Date.now();
|
|
5060
|
+
let settled = false;
|
|
5061
|
+
for (const row of this.rows) {
|
|
5062
|
+
if (row.kind !== 'compaction' || row.status !== 'running')
|
|
5063
|
+
continue;
|
|
5064
|
+
if (options.staleOnly === true && now - row.startedAt < COMPACTION_STALE_MS)
|
|
5065
|
+
continue;
|
|
5066
|
+
row.status = 'error';
|
|
5067
|
+
row.endedAt = row.startedAt === 0 ? now : row.startedAt;
|
|
5068
|
+
row.error = t('compact.interrupted');
|
|
5069
|
+
settled = true;
|
|
5070
|
+
}
|
|
5071
|
+
if (settled)
|
|
5072
|
+
this.markDirty();
|
|
4564
5073
|
}
|
|
4565
5074
|
handleCompactionEvent(type, event) {
|
|
4566
5075
|
const payload = event.data;
|
|
@@ -4671,11 +5180,14 @@ export class SshTui {
|
|
|
4671
5180
|
canRunCompactCommand() {
|
|
4672
5181
|
if (this.replaying || this.agentGone || this.exiting)
|
|
4673
5182
|
return false;
|
|
5183
|
+
// A card left running by a Host that died would otherwise refuse /compact
|
|
5184
|
+
// for the rest of the session; its age decides whether it is still real.
|
|
5185
|
+
this.settleUnfinishedCompactions({ staleOnly: true });
|
|
4674
5186
|
if (this.agent.status === 'running')
|
|
4675
5187
|
return false;
|
|
4676
5188
|
if (this.idleCompactInFlight)
|
|
4677
5189
|
return false;
|
|
4678
|
-
if (this.
|
|
5190
|
+
if (this.compactionRunning())
|
|
4679
5191
|
return false;
|
|
4680
5192
|
return this.ctx.get('commands') !== undefined;
|
|
4681
5193
|
}
|
|
@@ -4695,8 +5207,13 @@ export class SshTui {
|
|
|
4695
5207
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4696
5208
|
return;
|
|
4697
5209
|
}
|
|
5210
|
+
// A card left running by a Host that died is closed here rather than
|
|
5211
|
+
// refusing the user for the rest of the session: its age decides whether it
|
|
5212
|
+
// is still real. The footer already ignores a stale card, but the card
|
|
5213
|
+
// itself must stop rendering as running too.
|
|
5214
|
+
this.settleUnfinishedCompactions({ staleOnly: true });
|
|
4698
5215
|
if (this.agent.status === 'running'
|
|
4699
|
-
|| this.
|
|
5216
|
+
|| this.compactionRunning()) {
|
|
4700
5217
|
if (reason === 'user')
|
|
4701
5218
|
this.pushRow({ kind: 'error', text: t('compact.busy') });
|
|
4702
5219
|
this.markDirty();
|
|
@@ -4727,7 +5244,7 @@ export class SshTui {
|
|
|
4727
5244
|
this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
|
|
4728
5245
|
return;
|
|
4729
5246
|
}
|
|
4730
|
-
const compactionRunning = () => this.
|
|
5247
|
+
const compactionRunning = () => this.compactionRunning();
|
|
4731
5248
|
const releaseIfSettled = () => {
|
|
4732
5249
|
queueMicrotask(() => {
|
|
4733
5250
|
if (!compactionRunning())
|
|
@@ -4828,7 +5345,7 @@ export class SshTui {
|
|
|
4828
5345
|
if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
|
|
4829
5346
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
4830
5347
|
}
|
|
4831
|
-
if (!this.
|
|
5348
|
+
if (!this.compactionRunning()) {
|
|
4832
5349
|
this.idleCompactInFlight = false;
|
|
4833
5350
|
}
|
|
4834
5351
|
this.markDirty();
|
|
@@ -4911,40 +5428,75 @@ export class SshTui {
|
|
|
4911
5428
|
const row = this.findSubagentRow(String(sessionId));
|
|
4912
5429
|
if (row === undefined)
|
|
4913
5430
|
return;
|
|
4914
|
-
if (sessionEventType(event) === 'assistant/chunk')
|
|
4915
|
-
return;
|
|
4916
5431
|
switch (event.type) {
|
|
4917
5432
|
case 'user/message': {
|
|
4918
5433
|
const text = collectText(event.data.content);
|
|
4919
|
-
|
|
4920
|
-
|
|
5434
|
+
const source = event.data.source;
|
|
5435
|
+
const sourceKind = typeof source?.kind === 'string' ? source.kind : 'user';
|
|
5436
|
+
const plugin = typeof source?.plugin === 'string' ? source.plugin : undefined;
|
|
5437
|
+
foldSubagentUserLog(row, text, sourceKind, plugin);
|
|
4921
5438
|
break;
|
|
4922
5439
|
}
|
|
4923
5440
|
case 'assistant/message': {
|
|
4924
5441
|
const text = collectText(event.data.message.content);
|
|
5442
|
+
// Long enough to be worth opening the overlay for; the collapsed chip
|
|
5443
|
+
// reads `lastActivity`, not this entry.
|
|
4925
5444
|
if (text !== '')
|
|
4926
|
-
appendSubagentLog(row, { kind: 'assistant', text:
|
|
5445
|
+
appendSubagentLog(row, { kind: 'assistant', text: clipSubagentActivity(text, 200) });
|
|
4927
5446
|
break;
|
|
4928
5447
|
}
|
|
4929
5448
|
case 'tool/call': {
|
|
4930
5449
|
if (HIDDEN_TOOL_NAMES.has(event.data.name))
|
|
4931
5450
|
break;
|
|
5451
|
+
this.toolCallNames.set(String(event.data.callId), event.data.name);
|
|
4932
5452
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4933
|
-
appendSubagentLog(row, {
|
|
5453
|
+
appendSubagentLog(row, {
|
|
5454
|
+
kind: 'tool',
|
|
5455
|
+
text: `▶ ${present.title} ${clipSubagentActivity(present.summary, 48)}`.trimEnd(),
|
|
5456
|
+
callId: String(event.data.callId),
|
|
5457
|
+
});
|
|
4934
5458
|
break;
|
|
4935
5459
|
}
|
|
4936
5460
|
case 'tool/result': {
|
|
4937
|
-
const
|
|
4938
|
-
const
|
|
5461
|
+
const ok = event.data.error === undefined && !toolResultFailed(event.data.message);
|
|
5462
|
+
const callId = String(event.data.message.source.callId);
|
|
5463
|
+
const sourceName = event.data.message.source.name;
|
|
5464
|
+
const recorded = this.toolCallNames.get(callId)
|
|
5465
|
+
?? (typeof sourceName === 'string' ? sourceName : '');
|
|
5466
|
+
const title = toolTitle(displayToolName(recorded));
|
|
5467
|
+
// The overlay is the only place a child's tool output can be read, so
|
|
5468
|
+
// keep a few lines of it — the call line itself carries the title.
|
|
5469
|
+
const output = truncate(collectText(event.data.message.content), 3).trim();
|
|
4939
5470
|
appendSubagentLog(row, {
|
|
4940
5471
|
kind: 'result',
|
|
4941
|
-
text: `${ok ? '✓' : '✗'} ${
|
|
5472
|
+
text: `${ok ? '✓' : '✗'} ${title}`,
|
|
5473
|
+
...(output === '' ? {} : { detail: output }),
|
|
5474
|
+
callId,
|
|
4942
5475
|
});
|
|
4943
5476
|
break;
|
|
4944
5477
|
}
|
|
4945
|
-
case 'turn/end':
|
|
4946
|
-
|
|
5478
|
+
case 'turn/end': {
|
|
5479
|
+
const reason = event.data.reason;
|
|
5480
|
+
const error = reason.kind === 'error'
|
|
5481
|
+
? String(reason.error?.message ?? '')
|
|
5482
|
+
: '';
|
|
5483
|
+
const explained = describeSubagentFailure({
|
|
5484
|
+
stopReason: reason.kind,
|
|
5485
|
+
message: error,
|
|
5486
|
+
provider: row.modelProvider ?? this.currentProviderId(),
|
|
5487
|
+
});
|
|
5488
|
+
if (explained !== undefined)
|
|
5489
|
+
row.failHint = explained.hint;
|
|
5490
|
+
appendSubagentLog(row, {
|
|
5491
|
+
kind: error === '' ? 'turn' : 'result',
|
|
5492
|
+
text: explained === undefined
|
|
5493
|
+
? (error === ''
|
|
5494
|
+
? t('sub.turnEnd', { reason: reason.kind })
|
|
5495
|
+
: t('sub.turnEndError', { reason: reason.kind, error }))
|
|
5496
|
+
: explained.hint,
|
|
5497
|
+
});
|
|
4947
5498
|
break;
|
|
5499
|
+
}
|
|
4948
5500
|
case 'approval/asked':
|
|
4949
5501
|
appendSubagentLog(row, { kind: 'approval', text: t('sub.approval', { tool: event.data.toolName }) });
|
|
4950
5502
|
break;
|
|
@@ -4953,6 +5505,7 @@ export class SshTui {
|
|
|
4953
5505
|
break;
|
|
4954
5506
|
}
|
|
4955
5507
|
this.lastActivity = Date.now();
|
|
5508
|
+
this.refreshOpenSubagentInspect(row);
|
|
4956
5509
|
this.markDirty();
|
|
4957
5510
|
};
|
|
4958
5511
|
handleSubagentStart = (info) => {
|
|
@@ -4964,31 +5517,53 @@ export class SshTui {
|
|
|
4964
5517
|
});
|
|
4965
5518
|
this.subagentSessions.add(sessionId);
|
|
4966
5519
|
this.lastActivity = Date.now();
|
|
5520
|
+
// The spawn description is a FIFO pair (task + parent call id). A card that
|
|
5521
|
+
// is already linked to a spawn keeps it: only a fresh child may take the
|
|
5522
|
+
// oldest waiting description, or two parallel spawns swap names.
|
|
4967
5523
|
const existing = this.findSubagentRow(sessionId);
|
|
5524
|
+
const pending = existing?.spawnCallId === undefined ? this.pendingSubagentTasks.shift() : undefined;
|
|
5525
|
+
const task = pending?.task;
|
|
5526
|
+
const modelProvider = this.subagentSelection.current.provider ?? this.currentProviderId();
|
|
5527
|
+
const startedText = t('sub.startedDetail', {
|
|
5528
|
+
provider: modelProvider,
|
|
5529
|
+
external: info.local ? '' : t('sub.external'),
|
|
5530
|
+
});
|
|
4968
5531
|
if (existing !== undefined) {
|
|
5532
|
+
existing.childSessionId = sessionId;
|
|
4969
5533
|
existing.runId = String(info.runId);
|
|
4970
5534
|
existing.provider = info.provider;
|
|
5535
|
+
existing.modelProvider = modelProvider;
|
|
4971
5536
|
existing.local = info.local;
|
|
4972
5537
|
existing.status = 'running';
|
|
4973
5538
|
existing.startedAt = Date.now();
|
|
4974
5539
|
existing.endedAt = undefined;
|
|
4975
5540
|
existing.stopReason = undefined;
|
|
5541
|
+
delete existing.failHint;
|
|
4976
5542
|
existing.lastActivity = t('sub.started');
|
|
4977
5543
|
existing.expanded = false;
|
|
4978
|
-
|
|
5544
|
+
if (pending !== undefined)
|
|
5545
|
+
existing.spawnCallId = pending.callId;
|
|
5546
|
+
if (task !== undefined)
|
|
5547
|
+
existing.task = task;
|
|
5548
|
+
appendSubagentLog(existing, { kind: 'system', text: startedText });
|
|
5549
|
+
this.refreshOpenSubagentInspect(existing);
|
|
4979
5550
|
}
|
|
4980
5551
|
else {
|
|
4981
5552
|
this.pushRow({
|
|
4982
5553
|
kind: 'subagent',
|
|
4983
5554
|
sessionId,
|
|
5555
|
+
childSessionId: sessionId,
|
|
5556
|
+
...(pending === undefined ? {} : { spawnCallId: pending.callId }),
|
|
4984
5557
|
runId: String(info.runId),
|
|
4985
5558
|
provider: info.provider,
|
|
5559
|
+
modelProvider,
|
|
4986
5560
|
local: info.local,
|
|
4987
5561
|
label: t('sub.label', { provider: info.provider }),
|
|
5562
|
+
...(task === undefined ? {} : { task }),
|
|
4988
5563
|
status: 'running',
|
|
4989
5564
|
startedAt: Date.now(),
|
|
4990
5565
|
lastActivity: t('sub.started'),
|
|
4991
|
-
logs: [{ kind: 'system', text:
|
|
5566
|
+
logs: [{ kind: 'system', text: startedText }],
|
|
4992
5567
|
expanded: false,
|
|
4993
5568
|
});
|
|
4994
5569
|
}
|
|
@@ -5000,37 +5575,79 @@ export class SshTui {
|
|
|
5000
5575
|
this.lastActivity = Date.now();
|
|
5001
5576
|
const output = info.lastAssistantMessage === undefined
|
|
5002
5577
|
? ''
|
|
5003
|
-
:
|
|
5004
|
-
const row = this.findSubagentRow(String(info.id))
|
|
5578
|
+
: clipSubagentActivity(collectText(info.lastAssistantMessage), 80);
|
|
5579
|
+
const row = this.findSubagentRow(String(info.id))
|
|
5580
|
+
?? this.rows.findLast((candidate) => candidate.kind === 'subagent' && candidate.runId === String(info.runId))
|
|
5581
|
+
// A chip rebuilt from the parent spawn tool has no child id yet: adopt the
|
|
5582
|
+
// oldest one so an end event settles it instead of adding a twin. A chip
|
|
5583
|
+
// replay already closed as `unknown` is adopted too, and corrected.
|
|
5584
|
+
?? this.rows.find((candidate) => candidate.kind === 'subagent'
|
|
5585
|
+
&& (candidate.status === 'running' || candidate.stopReason === 'unknown')
|
|
5586
|
+
&& candidate.childSessionId === undefined);
|
|
5005
5587
|
const failed = info.stopReason !== 'completed';
|
|
5588
|
+
const modelProvider = row?.modelProvider
|
|
5589
|
+
?? this.subagentSelection.current.provider
|
|
5590
|
+
?? this.currentProviderId();
|
|
5591
|
+
const explained = failed
|
|
5592
|
+
? describeSubagentFailure({
|
|
5593
|
+
stopReason: info.stopReason,
|
|
5594
|
+
message: output,
|
|
5595
|
+
provider: modelProvider,
|
|
5596
|
+
})
|
|
5597
|
+
: undefined;
|
|
5598
|
+
// A healthy end clears any hint an earlier failed attempt left on the row.
|
|
5599
|
+
const hint = explained?.hint;
|
|
5600
|
+
const endText = hint === undefined
|
|
5601
|
+
? t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`)
|
|
5602
|
+
: hint;
|
|
5006
5603
|
if (row !== undefined) {
|
|
5007
5604
|
row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
|
|
5008
5605
|
row.endedAt = Date.now();
|
|
5009
5606
|
row.stopReason = info.stopReason;
|
|
5607
|
+
row.childSessionId = String(info.id);
|
|
5608
|
+
row.modelProvider = modelProvider;
|
|
5609
|
+
if (hint === undefined)
|
|
5610
|
+
delete row.failHint;
|
|
5611
|
+
else
|
|
5612
|
+
row.failHint = hint;
|
|
5010
5613
|
appendSubagentLog(row, {
|
|
5011
5614
|
kind: failed ? 'result' : 'assistant',
|
|
5012
|
-
text:
|
|
5615
|
+
text: endText,
|
|
5013
5616
|
});
|
|
5617
|
+
this.refreshOpenSubagentInspect(row);
|
|
5014
5618
|
}
|
|
5015
5619
|
else {
|
|
5016
5620
|
this.pushRow({
|
|
5017
5621
|
kind: 'subagent',
|
|
5018
5622
|
sessionId: String(info.id),
|
|
5623
|
+
childSessionId: String(info.id),
|
|
5019
5624
|
runId: String(info.runId),
|
|
5020
5625
|
provider: info.provider,
|
|
5626
|
+
modelProvider,
|
|
5021
5627
|
local: info.local,
|
|
5022
5628
|
label: t('sub.label', { provider: info.provider }),
|
|
5023
5629
|
status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
|
|
5024
5630
|
startedAt: Date.now(),
|
|
5025
5631
|
endedAt: Date.now(),
|
|
5026
5632
|
stopReason: info.stopReason,
|
|
5027
|
-
|
|
5028
|
-
|
|
5633
|
+
...(explained === undefined ? {} : { failHint: explained.hint }),
|
|
5634
|
+
lastActivity: endText,
|
|
5635
|
+
logs: [{ kind: failed ? 'result' : 'system', text: endText }],
|
|
5029
5636
|
expanded: false,
|
|
5030
5637
|
});
|
|
5031
5638
|
}
|
|
5032
5639
|
this.markDirty();
|
|
5033
5640
|
};
|
|
5641
|
+
/** Keep an open inspect overlay in sync with the live child log. */
|
|
5642
|
+
refreshOpenSubagentInspect(row) {
|
|
5643
|
+
const dialog = this.dialog;
|
|
5644
|
+
if (dialog === undefined || dialog.kind !== 'inspect')
|
|
5645
|
+
return;
|
|
5646
|
+
if (dialog.subagentSessionId !== this.subagentInspectId(row))
|
|
5647
|
+
return;
|
|
5648
|
+
dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
|
|
5649
|
+
dialog.lines = subagentInspectLines(row);
|
|
5650
|
+
}
|
|
5034
5651
|
// ── approval and questions ──────────────────────────────────────────────
|
|
5035
5652
|
hasLiveDisplay() {
|
|
5036
5653
|
if (this.disposed || this.exiting)
|
|
@@ -5120,7 +5737,7 @@ export class SshTui {
|
|
|
5120
5737
|
const selection = this.subagentSelection.current;
|
|
5121
5738
|
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5122
5739
|
const provider = selection.provider ?? parentProvider;
|
|
5123
|
-
const model = subagentModelMatchesProvider(provider, selection.model)
|
|
5740
|
+
const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
|
|
5124
5741
|
? selection.model
|
|
5125
5742
|
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
5126
5743
|
return { provider, model };
|
|
@@ -5346,9 +5963,7 @@ export class SshTui {
|
|
|
5346
5963
|
return 'cancelled';
|
|
5347
5964
|
}
|
|
5348
5965
|
}
|
|
5349
|
-
const agentLabel =
|
|
5350
|
-
? t('approval.thisSession')
|
|
5351
|
-
: t('sub.agentLabel', { id: request.agent.id });
|
|
5966
|
+
const agentLabel = t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5352
5967
|
return new Promise((resolve) => {
|
|
5353
5968
|
if (request.signal?.aborted === true) {
|
|
5354
5969
|
resolve('cancelled');
|
|
@@ -5386,7 +6001,7 @@ export class SshTui {
|
|
|
5386
6001
|
const answers = [];
|
|
5387
6002
|
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
5388
6003
|
? undefined
|
|
5389
|
-
: t('sub.agentLabel', {
|
|
6004
|
+
: t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
|
|
5390
6005
|
const cards = [];
|
|
5391
6006
|
for (const question of request.questions) {
|
|
5392
6007
|
const card = {
|
|
@@ -5463,6 +6078,7 @@ export class SshTui {
|
|
|
5463
6078
|
openDialog(dialog) {
|
|
5464
6079
|
if (this.dialog === undefined) {
|
|
5465
6080
|
this.dialog = dialog;
|
|
6081
|
+
this.echoLineModeDialog(dialog);
|
|
5466
6082
|
}
|
|
5467
6083
|
else {
|
|
5468
6084
|
this.dialogQueue.push(dialog);
|
|
@@ -5475,9 +6091,53 @@ export class SshTui {
|
|
|
5475
6091
|
const next = this.dialogQueue.shift();
|
|
5476
6092
|
if (next !== undefined) {
|
|
5477
6093
|
this.dialog = next;
|
|
6094
|
+
this.echoLineModeDialog(next);
|
|
5478
6095
|
this.markDirty();
|
|
5479
6096
|
}
|
|
5480
6097
|
}
|
|
6098
|
+
/**
|
|
6099
|
+
* Line mode has no framed dialog. Write the prompt (and its keys) into the
|
|
6100
|
+
* log once when it becomes active, so a `tee` or a screen reader can answer
|
|
6101
|
+
* it. Options stay on the framed path; the log only needs the question.
|
|
6102
|
+
*/
|
|
6103
|
+
echoLineModeDialog(dialog) {
|
|
6104
|
+
if (!this.lineMode)
|
|
6105
|
+
return;
|
|
6106
|
+
if (dialog.kind === 'confirm') {
|
|
6107
|
+
this.pushRow({ kind: 'system', text: dialog.prompt });
|
|
6108
|
+
this.pushRow({ kind: 'system', text: dialog.hint });
|
|
6109
|
+
return;
|
|
6110
|
+
}
|
|
6111
|
+
if (dialog.kind === 'questions') {
|
|
6112
|
+
const header = t('dialog.ask', {
|
|
6113
|
+
index: dialog.index + 1,
|
|
6114
|
+
total: dialog.total,
|
|
6115
|
+
question: dialog.question.question,
|
|
6116
|
+
});
|
|
6117
|
+
this.pushRow({ kind: 'system', text: header });
|
|
6118
|
+
if (dialog.question.header !== undefined && dialog.question.header !== '') {
|
|
6119
|
+
this.pushRow({ kind: 'system', text: dialog.question.header });
|
|
6120
|
+
}
|
|
6121
|
+
const options = dialog.question.options ?? [];
|
|
6122
|
+
for (const [index, option] of options.entries()) {
|
|
6123
|
+
if (option === undefined)
|
|
6124
|
+
continue;
|
|
6125
|
+
const key = QUESTION_OPTION_KEYS[index] ?? String(index + 1);
|
|
6126
|
+
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
6127
|
+
this.pushRow({ kind: 'system', text: ` ${key} ${option.label}${extra}` });
|
|
6128
|
+
}
|
|
6129
|
+
this.pushRow({
|
|
6130
|
+
kind: 'system',
|
|
6131
|
+
text: options.length === 0
|
|
6132
|
+
? t('dialog.freeform')
|
|
6133
|
+
: dialog.question.multiSelect === true ? t('dialog.multiHint') : t('dialog.singleHint'),
|
|
6134
|
+
});
|
|
6135
|
+
return;
|
|
6136
|
+
}
|
|
6137
|
+
if (dialog.kind === 'onboarding') {
|
|
6138
|
+
this.pushRow({ kind: 'system', text: t('onboard.title') });
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
5481
6141
|
removeQueuedDialog(dialog) {
|
|
5482
6142
|
const index = this.dialogQueue.indexOf(dialog);
|
|
5483
6143
|
if (index !== -1)
|
|
@@ -5557,14 +6217,195 @@ export class SshTui {
|
|
|
5557
6217
|
piAiProviderProfile(provider) {
|
|
5558
6218
|
if (provider === 'deepseek-official')
|
|
5559
6219
|
return undefined;
|
|
5560
|
-
const section = this.ctx
|
|
6220
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
5561
6221
|
return section?.providers?.[provider];
|
|
5562
6222
|
}
|
|
5563
|
-
/**
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
6223
|
+
/**
|
|
6224
|
+
* Every settings-backed row one gateway family occupies, base first.
|
|
6225
|
+
*
|
|
6226
|
+
* `llm-pi-ai` puts the wire protocol on the provider entry, so a gateway
|
|
6227
|
+
* whose catalogue spans protocols (Command Code, OpenCode Go) is stored as
|
|
6228
|
+
* sibling rows. To the user they are one supplier: the TUI lists, picks and
|
|
6229
|
+
* remembers them under the family's base id, and files each picked model on
|
|
6230
|
+
* the row that actually serves it.
|
|
6231
|
+
*
|
|
6232
|
+
* The `-responses` / `-completions` / `-messages` suffix is only a naming
|
|
6233
|
+
* convention, so a row is adopted into the family only when it corroborates:
|
|
6234
|
+
* it must speak the protocol its suffix names, point at the same endpoint,
|
|
6235
|
+
* and (when both name one) carry the same display name. A provider someone
|
|
6236
|
+
* legitimately called `foo-messages` on another host therefore stays its own
|
|
6237
|
+
* supplier instead of vanishing from `/provider` behind `foo`. A family of
|
|
6238
|
+
* one — the ordinary case, and a sibling whose base row is missing — stays
|
|
6239
|
+
* exactly as configured.
|
|
6240
|
+
*/
|
|
6241
|
+
providerFamilyRows(provider) {
|
|
6242
|
+
if (provider === '')
|
|
6243
|
+
return [];
|
|
6244
|
+
const base = baseProviderIdOf(provider);
|
|
6245
|
+
const baseRow = this.piAiProviderProfile(base);
|
|
6246
|
+
const rows = [base];
|
|
6247
|
+
for (const protocol of GATEWAY_PROTOCOL_PREFERENCE) {
|
|
6248
|
+
const sibling = siblingProviderId(base, protocol);
|
|
6249
|
+
if (sibling === provider || rows.includes(sibling)) {
|
|
6250
|
+
if (this.isFamilyRow(baseRow, sibling, protocol))
|
|
6251
|
+
rows.push(sibling);
|
|
6252
|
+
continue;
|
|
6253
|
+
}
|
|
6254
|
+
if (this.isFamilyRow(baseRow, sibling, protocol))
|
|
6255
|
+
rows.push(sibling);
|
|
6256
|
+
}
|
|
6257
|
+
const present = rows.filter(row => this.piAiProviderProfile(row) !== undefined);
|
|
6258
|
+
if (present.length <= 1) {
|
|
6259
|
+
// A lone sibling is not a family — it is just this provider.
|
|
6260
|
+
return baseRow !== undefined || present.includes(provider) ? present : [provider];
|
|
6261
|
+
}
|
|
6262
|
+
return present;
|
|
6263
|
+
}
|
|
6264
|
+
/**
|
|
6265
|
+
* Whether `sibling` is genuinely the same gateway as `base`, on another
|
|
6266
|
+
* protocol: its `api` matches its suffix, its endpoint matches the base row's,
|
|
6267
|
+
* and the two labels agree when both are set.
|
|
6268
|
+
*/
|
|
6269
|
+
isFamilyRow(baseRow, sibling, protocol) {
|
|
6270
|
+
if (baseRow === undefined)
|
|
6271
|
+
return false;
|
|
6272
|
+
const profile = this.piAiProviderProfile(sibling);
|
|
6273
|
+
if (profile === undefined)
|
|
6274
|
+
return false;
|
|
6275
|
+
const endpoint = (value) => typeof value === 'string' ? value.replace(/\/+$/u, '').toLowerCase() : '';
|
|
6276
|
+
const api = declaredProtocol(profile.api);
|
|
6277
|
+
if (api !== undefined && api !== protocol)
|
|
6278
|
+
return false;
|
|
6279
|
+
const baseUrl = endpoint(baseRow.baseURL);
|
|
6280
|
+
const siblingUrl = endpoint(profile.baseURL);
|
|
6281
|
+
if (baseUrl !== '' && siblingUrl !== '' && baseUrl !== siblingUrl)
|
|
6282
|
+
return false;
|
|
6283
|
+
const baseLabel = typeof baseRow.displayName === 'string' ? baseRow.displayName.trim() : '';
|
|
6284
|
+
const siblingLabel = typeof profile.displayName === 'string' ? profile.displayName.trim() : '';
|
|
6285
|
+
if (baseLabel !== '' && siblingLabel !== '' && baseLabel !== siblingLabel)
|
|
6286
|
+
return false;
|
|
6287
|
+
return true;
|
|
6288
|
+
}
|
|
6289
|
+
/** The id the TUI shows for a route: its family's base when it is spread. */
|
|
6290
|
+
displayProviderId(provider) {
|
|
6291
|
+
const rows = this.providerFamilyRows(provider);
|
|
6292
|
+
return rows.length > 1 ? (rows[0] ?? provider) : provider;
|
|
6293
|
+
}
|
|
6294
|
+
/** The models one row configures, as ids with the labels settings carry. */
|
|
6295
|
+
configuredModelsOf(row) {
|
|
6296
|
+
const models = this.piAiProviderProfile(row)?.models;
|
|
6297
|
+
if (!Array.isArray(models))
|
|
6298
|
+
return [];
|
|
6299
|
+
const out = [];
|
|
6300
|
+
for (const raw of models) {
|
|
6301
|
+
if (typeof raw === 'string') {
|
|
6302
|
+
if (raw !== '')
|
|
6303
|
+
out.push({ id: raw });
|
|
6304
|
+
continue;
|
|
6305
|
+
}
|
|
6306
|
+
if (raw === null || typeof raw !== 'object')
|
|
6307
|
+
continue;
|
|
6308
|
+
const entry = raw;
|
|
6309
|
+
if (typeof entry.id !== 'string' || entry.id === '')
|
|
6310
|
+
continue;
|
|
6311
|
+
out.push({
|
|
6312
|
+
id: entry.id,
|
|
6313
|
+
...(typeof entry.name === 'string' && entry.name !== '' ? { label: entry.name } : {}),
|
|
6314
|
+
});
|
|
6315
|
+
}
|
|
6316
|
+
return out;
|
|
6317
|
+
}
|
|
6318
|
+
/**
|
|
6319
|
+
* The protocols the gateway publishes for one model, best-effort.
|
|
6320
|
+
*
|
|
6321
|
+
* Used when a model is being filed but no picker listed it yet (`/model <id>`
|
|
6322
|
+
* on a new id, a `--model` override, a `/submodel` pin): the gateway's own
|
|
6323
|
+
* answer is the only thing that can put it on a route that can send it.
|
|
6324
|
+
* Any failure is "no opinion", never a blocked selection.
|
|
6325
|
+
*/
|
|
6326
|
+
async advertisedFor(family, model) {
|
|
6327
|
+
const rows = this.providerFamilyRows(family);
|
|
6328
|
+
if (rows.length <= 1)
|
|
6329
|
+
return [];
|
|
6330
|
+
// A model the last listing already filed needs no second opinion, and that
|
|
6331
|
+
// is the common case: `/model` re-picking a listed model must never wait on
|
|
6332
|
+
// the gateway again. Only an unlisted id (a hand-typed one, a `--model`
|
|
6333
|
+
// override) asks, and even then the wait is bounded — an unreachable gateway
|
|
6334
|
+
// must not stall a selection for the endpoint's full timeout.
|
|
6335
|
+
if (this.familyModelOwner.has(`${family}\u0000${model}`))
|
|
6336
|
+
return [];
|
|
6337
|
+
if (this.configuredModelsOf(rows[0] ?? family).some(entry => entry.id === model))
|
|
6338
|
+
return [];
|
|
6339
|
+
try {
|
|
6340
|
+
const listing = await this.discoverGatewayListing(rows[0] ?? family, AbortSignal.timeout(5_000));
|
|
6341
|
+
return advertisedProtocols(listing.endpoints, model);
|
|
6342
|
+
}
|
|
6343
|
+
catch {
|
|
6344
|
+
return [];
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6347
|
+
/**
|
|
6348
|
+
* The row of a gateway family that serves `model`: the one learned from the
|
|
6349
|
+
* last listing, else the one that already configures it, else the base —
|
|
6350
|
+
* where a brand-new model lands and is re-filed from.
|
|
6351
|
+
*/
|
|
6352
|
+
/**
|
|
6353
|
+
* Record which row a family's model belongs to, keeping the map bounded.
|
|
6354
|
+
*
|
|
6355
|
+
* Re-inserting keeps a key fresh, and the oldest half is dropped once the cap
|
|
6356
|
+
* is crossed, so a gateway whose catalogue is re-listed on every `/model`
|
|
6357
|
+
* does not accumulate a second copy of itself each time.
|
|
6358
|
+
*/
|
|
6359
|
+
rememberFamilyOwner(base, model, row) {
|
|
6360
|
+
const key = `${base}\u0000${model}`;
|
|
6361
|
+
this.familyModelOwner.delete(key);
|
|
6362
|
+
this.familyModelOwner.set(key, row);
|
|
6363
|
+
if (this.familyModelOwner.size <= FAMILY_OWNER_CACHE_MAX)
|
|
6364
|
+
return;
|
|
6365
|
+
let remaining = Math.floor(FAMILY_OWNER_CACHE_MAX / 2);
|
|
6366
|
+
for (const oldest of this.familyModelOwner.keys()) {
|
|
6367
|
+
if (remaining <= 0)
|
|
6368
|
+
break;
|
|
6369
|
+
this.familyModelOwner.delete(oldest);
|
|
6370
|
+
remaining -= 1;
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
familyOwnerRow(base, model, advertised) {
|
|
6374
|
+
const rows = this.providerFamilyRows(base);
|
|
6375
|
+
if (rows.length <= 1)
|
|
6376
|
+
return base;
|
|
6377
|
+
// What the gateway itself says comes first: a model no row configures and
|
|
6378
|
+
// no picker listed (`/model <id>` on a new id, a `--model` override) still
|
|
6379
|
+
// has a protocol, and filing it anywhere else makes the guard below refuse
|
|
6380
|
+
// a model the gateway is happy to serve.
|
|
6381
|
+
for (const protocol of advertised ?? []) {
|
|
6382
|
+
const row = rows.find(candidate => declaredProtocol(this.piAiProviderProfile(candidate)?.api) === protocol);
|
|
6383
|
+
if (row !== undefined)
|
|
6384
|
+
return row;
|
|
6385
|
+
}
|
|
6386
|
+
// The row that actually served the model in the last listing is evidence,
|
|
6387
|
+
// not a hint: it is the row the gateway said could send it. A row that
|
|
6388
|
+
// merely has the model configured used to win here, which sent an explicit
|
|
6389
|
+
// `/model <id>` back to the protocol the listing had just ruled out (the
|
|
6390
|
+
// completions row is still listed first for a stale responses entry).
|
|
6391
|
+
const learned = this.familyModelOwner.get(`${base}\u0000${model}`);
|
|
6392
|
+
if (learned !== undefined && rows.includes(learned))
|
|
6393
|
+
return learned;
|
|
6394
|
+
// No listing this run (a direct `/model <id>`, or an unreachable gateway):
|
|
6395
|
+
// keep the placement that is already configured. A model on two rows takes
|
|
6396
|
+
// the first, which is the family's own preference order — the same order
|
|
6397
|
+
// the wizard would file it under.
|
|
6398
|
+
for (const row of rows) {
|
|
6399
|
+
if (this.configuredModelsOf(row).some(entry => entry.id === model))
|
|
6400
|
+
return row;
|
|
6401
|
+
}
|
|
6402
|
+
return rows[0] ?? base;
|
|
6403
|
+
}
|
|
6404
|
+
/** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
|
|
6405
|
+
openCodeListingBaseURL(provider) {
|
|
6406
|
+
if (provider === 'opencode-go')
|
|
6407
|
+
return providerTemplates()['opencode-go'].defaultBaseUrl;
|
|
6408
|
+
if (provider === 'opencode')
|
|
5568
6409
|
return OPENCODE_ZEN_BASE_URL;
|
|
5569
6410
|
return undefined;
|
|
5570
6411
|
}
|
|
@@ -5573,16 +6414,36 @@ export class SshTui {
|
|
|
5573
6414
|
* OpenAI-compatible listing endpoint. The provider route is deliberately not
|
|
5574
6415
|
* passed to discovery: pi-ai would answer a catalog route from its installed
|
|
5575
6416
|
* registry, while the TUI wants the endpoint's current list.
|
|
6417
|
+
*
|
|
6418
|
+
* A row speaks exactly one protocol (`api` on the provider entry), so with a
|
|
6419
|
+
* gateway that publishes `supported_endpoints` the list is narrowed to the
|
|
6420
|
+
* models that row can actually serve — otherwise `/model` on
|
|
6421
|
+
* `command-code-messages` would offer, and then persist, a model that only
|
|
6422
|
+
* answers `/responses`.
|
|
5576
6423
|
*/
|
|
5577
6424
|
async discoverEndpointModels(provider) {
|
|
5578
|
-
const
|
|
6425
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
|
|
6426
|
+
// Interactive callers get a shorter budget than the wizard's: `/model`
|
|
6427
|
+
// falling back to the configured list after a few seconds reads as "this
|
|
6428
|
+
// gateway is unreachable", while 15s reads as "the TUI hung".
|
|
6429
|
+
const { models, endpoints } = await this.discoverGatewayListing(provider, AbortSignal.timeout(6_000));
|
|
6430
|
+
return models.filter(model => gatewayServesModel(endpoints, model.id, protocol));
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* One gateway's own model listing: every model the endpoint returns plus the
|
|
6434
|
+
* route table it publishes. The caller narrows it to a protocol; a gateway
|
|
6435
|
+
* spread over rows asks once and files the answer per row, so opening a
|
|
6436
|
+
* picker costs one listing instead of one per sibling.
|
|
6437
|
+
*/
|
|
6438
|
+
async discoverGatewayListing(provider, signal) {
|
|
6439
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
5579
6440
|
const profile = this.piAiProviderProfile(provider);
|
|
5580
6441
|
const source = openCodeSourceFor(provider, llmPiAi);
|
|
5581
6442
|
const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
5582
6443
|
? profile.baseURL.trim()
|
|
5583
6444
|
: this.openCodeListingBaseURL(provider);
|
|
5584
6445
|
if (baseURL === undefined)
|
|
5585
|
-
return [];
|
|
6446
|
+
return { models: [], endpoints: undefined };
|
|
5586
6447
|
const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
|
|
5587
6448
|
const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
|
|
5588
6449
|
? profile.apiKeyEnv.trim()
|
|
@@ -5590,13 +6451,50 @@ export class SshTui {
|
|
|
5590
6451
|
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
5591
6452
|
const llm = this.ctx.get('llm');
|
|
5592
6453
|
if (llm === undefined)
|
|
5593
|
-
return [];
|
|
5594
|
-
const discovered = await
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
6454
|
+
return { models: [], endpoints: undefined };
|
|
6455
|
+
const [discovered, endpoints] = await Promise.all([
|
|
6456
|
+
discoverProviderModels(llm, {
|
|
6457
|
+
baseURL,
|
|
6458
|
+
...(api === undefined ? {} : { api }),
|
|
6459
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
6460
|
+
}, signal ?? AbortSignal.timeout(15_000)).catch(() => []),
|
|
6461
|
+
apiKey === undefined ? Promise.resolve(undefined) : fetchModelEndpoints(baseURL, apiKey, signal),
|
|
6462
|
+
]);
|
|
6463
|
+
// The route table is the point of asking a gateway that publishes one: it is
|
|
6464
|
+
// what files each model on the protocol that can actually send it, and the
|
|
6465
|
+
// discovered list alone cannot. A gateway whose listing answered from the
|
|
6466
|
+
// installed catalog still gets its endpoints read here, and a gateway that
|
|
6467
|
+
// publishes no table keeps the discovered list as-is.
|
|
6468
|
+
const named = new Map(discovered.map(model => [model.id, model.name || model.id]));
|
|
6469
|
+
const models = endpoints === undefined
|
|
6470
|
+
? discovered.map(model => ({ id: model.id, label: model.name || model.id }))
|
|
6471
|
+
: [...endpoints.keys()].map(id => ({ id, label: named.get(id) ?? id }));
|
|
6472
|
+
return { models, endpoints };
|
|
6473
|
+
}
|
|
6474
|
+
/**
|
|
6475
|
+
* The row of the same gateway that the gateway itself says serves `model`,
|
|
6476
|
+
* when that is not the row the caller is on. `undefined` when the gateway has
|
|
6477
|
+
* no opinion, when the current row already serves it, or when the model would
|
|
6478
|
+
* need a row that does not exist yet.
|
|
6479
|
+
*/
|
|
6480
|
+
rowServingModel(provider, modelId, endpoints) {
|
|
6481
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
|
|
6482
|
+
if (gatewayServesModel(endpoints, modelId, protocol))
|
|
6483
|
+
return undefined;
|
|
6484
|
+
const wanted = advertisedProtocols(endpoints, modelId)[0];
|
|
6485
|
+
if (wanted === undefined)
|
|
6486
|
+
return undefined;
|
|
6487
|
+
// The base id is a row of its own: use it when it already speaks `wanted`,
|
|
6488
|
+
// otherwise the sibling that does.
|
|
6489
|
+
const base = baseProviderIdOf(provider);
|
|
6490
|
+
const rows = declaredProtocol(this.piAiProviderProfile(base)?.api) === wanted
|
|
6491
|
+
? [base, siblingProviderId(base, wanted)]
|
|
6492
|
+
: [siblingProviderId(base, wanted)];
|
|
6493
|
+
for (const row of rows) {
|
|
6494
|
+
if (row !== provider && this.piAiProviderProfile(row) !== undefined)
|
|
6495
|
+
return row;
|
|
6496
|
+
}
|
|
6497
|
+
return undefined;
|
|
5600
6498
|
}
|
|
5601
6499
|
/** Add one endpoint-listed model to the stored provider profile when needed. */
|
|
5602
6500
|
async ensureProviderModelConfigured(provider, modelId) {
|
|
@@ -5620,6 +6518,39 @@ export class SshTui {
|
|
|
5620
6518
|
}
|
|
5621
6519
|
if (ids.has(modelId))
|
|
5622
6520
|
return true;
|
|
6521
|
+
// Reached by a direct `/model <id>` too, where the picker's own filter never
|
|
6522
|
+
// ran: refuse to store a model this row's protocol cannot serve, and say
|
|
6523
|
+
// which sibling row can.
|
|
6524
|
+
const protocol = declaredProtocol(profile.api);
|
|
6525
|
+
const baseURL = typeof profile.baseURL === 'string' && profile.baseURL.trim() !== ''
|
|
6526
|
+
? profile.baseURL.trim()
|
|
6527
|
+
: this.openCodeListingBaseURL(provider);
|
|
6528
|
+
if (protocol !== undefined && baseURL !== undefined) {
|
|
6529
|
+
const apiKeyEnv = typeof profile.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== '' ? profile.apiKeyEnv.trim() : undefined;
|
|
6530
|
+
const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
|
|
6531
|
+
const endpoints = apiKey === undefined ? undefined : await fetchModelEndpoints(baseURL, apiKey);
|
|
6532
|
+
if (!gatewayServesModel(endpoints, modelId, protocol)) {
|
|
6533
|
+
const target = this.rowServingModel(provider, modelId, endpoints);
|
|
6534
|
+
// Report both names: the family is what `/provider` offers, the row is
|
|
6535
|
+
// what the settings file calls it. Sending the user to a hidden row was
|
|
6536
|
+
// a dead end — `/provider` lists families, so the hint names the family
|
|
6537
|
+
// and `/model` (which files the pick on the right row itself).
|
|
6538
|
+
const family = this.displayProviderId(provider);
|
|
6539
|
+
this.pushRow({
|
|
6540
|
+
kind: 'error',
|
|
6541
|
+
text: t('model.wrongRoute', {
|
|
6542
|
+
model: modelId,
|
|
6543
|
+
provider: family === provider ? provider : `${family}(${provider})`,
|
|
6544
|
+
endpoint: GATEWAY_PROTOCOL_ENDPOINT[protocol],
|
|
6545
|
+
hint: target === undefined
|
|
6546
|
+
? t('model.wrongRouteSetup')
|
|
6547
|
+
: t('model.wrongRouteTarget', { provider: family }),
|
|
6548
|
+
}),
|
|
6549
|
+
});
|
|
6550
|
+
this.markDirty();
|
|
6551
|
+
return false;
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
5623
6554
|
const modelEntry = { id: modelId };
|
|
5624
6555
|
// A model added here skips the wizard, so size it from the installed
|
|
5625
6556
|
// catalog the same way `/setup` does; the route default covers a miss.
|
|
@@ -5629,14 +6560,18 @@ export class SshTui {
|
|
|
5629
6560
|
if (contextWindow !== undefined)
|
|
5630
6561
|
modelEntry.contextWindow = contextWindow;
|
|
5631
6562
|
}
|
|
5632
|
-
|
|
6563
|
+
// Same rule `/setup` follows: a route whose dialect the installed catalog
|
|
6564
|
+
// does not describe must still declare its offered levels, or the Harness
|
|
6565
|
+
// reads the model as non-reasoning and refuses every effort the TUI offers.
|
|
6566
|
+
const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning)
|
|
6567
|
+
?? (declaresOfferedReasoning(typeof profile.api === 'string' ? profile.api : '') ? handDeclaredReasoningEfforts() : undefined);
|
|
5633
6568
|
if (reasoningEfforts !== undefined)
|
|
5634
6569
|
modelEntry.reasoningEfforts = reasoningEfforts;
|
|
5635
6570
|
try {
|
|
5636
6571
|
await settings.mutate(settingsNamespace('llm-pi-ai'), [
|
|
5637
6572
|
{ op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
|
|
5638
6573
|
]);
|
|
5639
|
-
this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider }) });
|
|
6574
|
+
this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider: this.displayProviderId(provider) }) });
|
|
5640
6575
|
this.markDirty();
|
|
5641
6576
|
return true;
|
|
5642
6577
|
}
|
|
@@ -5681,16 +6616,22 @@ export class SshTui {
|
|
|
5681
6616
|
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
5682
6617
|
listSelectableProviders() {
|
|
5683
6618
|
const llm = this.ctx.get('llm');
|
|
5684
|
-
const current = this.currentProviderId();
|
|
6619
|
+
const current = this.displayProviderId(this.currentProviderId());
|
|
5685
6620
|
const seen = new Set();
|
|
5686
6621
|
const out = [];
|
|
5687
6622
|
const add = (id, name) => {
|
|
5688
|
-
if (id === ''
|
|
6623
|
+
if (id === '')
|
|
5689
6624
|
return;
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
6625
|
+
// A gateway spread over protocol rows is one choice here: the rows are an
|
|
6626
|
+
// implementation detail of `llm-pi-ai` (one wire protocol per entry), not
|
|
6627
|
+
// something the user should have to pick between.
|
|
6628
|
+
const row = this.displayProviderId(id);
|
|
6629
|
+
if (row === '' || seen.has(row))
|
|
6630
|
+
return;
|
|
6631
|
+
seen.add(row);
|
|
6632
|
+
const kind = describeProviderRoute(row);
|
|
6633
|
+
const display = name !== undefined && name !== '' && name !== row ? name : kind.short;
|
|
6634
|
+
out.push({ id: row, label: `${display} · ${row}` });
|
|
5694
6635
|
};
|
|
5695
6636
|
add(current);
|
|
5696
6637
|
for (const info of llm?.listProviders() ?? [])
|
|
@@ -5708,6 +6649,9 @@ export class SshTui {
|
|
|
5708
6649
|
{ id: 'grok-4.3', label: 'Grok 4.3' },
|
|
5709
6650
|
];
|
|
5710
6651
|
async loadModelOptions(provider) {
|
|
6652
|
+
const family = this.providerFamilyRows(provider);
|
|
6653
|
+
if (family.length > 1)
|
|
6654
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
5711
6655
|
const llm = this.ctx.get('llm');
|
|
5712
6656
|
let options = [];
|
|
5713
6657
|
let source = t('model.configured');
|
|
@@ -5762,16 +6706,86 @@ export class SshTui {
|
|
|
5762
6706
|
}
|
|
5763
6707
|
return { options, source };
|
|
5764
6708
|
}
|
|
6709
|
+
/**
|
|
6710
|
+
* The model list of a gateway spread over protocol rows.
|
|
6711
|
+
*
|
|
6712
|
+
* Every row is asked what it serves and the answers are merged under the
|
|
6713
|
+
* family's base id, so the user picks from one list. Each model remembers the
|
|
6714
|
+
* row that offered it, which is what routes the pick back to the protocol
|
|
6715
|
+
* that can actually send it — the split stays invisible end to end.
|
|
6716
|
+
*/
|
|
6717
|
+
async loadFamilyModelOptions(base, family) {
|
|
6718
|
+
const llm = this.ctx.get('llm');
|
|
6719
|
+
const options = [];
|
|
6720
|
+
const seen = new Set();
|
|
6721
|
+
const add = (row, id, label) => {
|
|
6722
|
+
if (id === '' || seen.has(id))
|
|
6723
|
+
return;
|
|
6724
|
+
seen.add(id);
|
|
6725
|
+
this.rememberFamilyOwner(base, id, row);
|
|
6726
|
+
options.push({ id, label: label !== undefined && label !== '' ? label : id });
|
|
6727
|
+
};
|
|
6728
|
+
const previousStatus = this.status;
|
|
6729
|
+
this.status = t('model.fetching', { provider: base });
|
|
6730
|
+
this.markDirty();
|
|
6731
|
+
try {
|
|
6732
|
+
// The gateway's own listing is what decides which row serves what, so it
|
|
6733
|
+
// is answered first and `add` keeps the first row that claims an id. Doing
|
|
6734
|
+
// the configured rows first would let a leftover entry on the base row
|
|
6735
|
+
// shadow the sibling the gateway actually files the model under — the
|
|
6736
|
+
// picker would then show a model under a protocol that cannot send it,
|
|
6737
|
+
// and `/model` would write it back onto that row.
|
|
6738
|
+
const listings = await this.discoverGatewayListing(base, AbortSignal.timeout(6_000)).catch(() => undefined);
|
|
6739
|
+
if (listings !== undefined) {
|
|
6740
|
+
for (const row of family) {
|
|
6741
|
+
const protocol = declaredProtocol(this.piAiProviderProfile(row)?.api);
|
|
6742
|
+
for (const model of listings.models) {
|
|
6743
|
+
if (gatewayServesModel(listings.endpoints, model.id, protocol))
|
|
6744
|
+
add(row, model.id, model.label);
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
}
|
|
6748
|
+
// Whatever the endpoint did not answer: the rows that already configure a
|
|
6749
|
+
// model keep it (so a gateway that is down still offers its own list).
|
|
6750
|
+
for (const row of family) {
|
|
6751
|
+
for (const model of this.configuredModelsOf(row))
|
|
6752
|
+
add(row, model.id, model.label);
|
|
6753
|
+
}
|
|
6754
|
+
// Models the installed catalog knows but neither source listed.
|
|
6755
|
+
for (const row of family) {
|
|
6756
|
+
try {
|
|
6757
|
+
for (const model of (await llm?.listModels(row)) ?? [])
|
|
6758
|
+
add(row, model.id, model.name || model.id);
|
|
6759
|
+
}
|
|
6760
|
+
catch {
|
|
6761
|
+
// The endpoint listing stands alone when the catalog cannot be read.
|
|
6762
|
+
}
|
|
6763
|
+
}
|
|
6764
|
+
}
|
|
6765
|
+
finally {
|
|
6766
|
+
this.status = previousStatus;
|
|
6767
|
+
this.markDirty();
|
|
6768
|
+
}
|
|
6769
|
+
if (options.length === 0) {
|
|
6770
|
+
const remembered = this.rememberedRoute(base)?.model;
|
|
6771
|
+
if (remembered !== undefined)
|
|
6772
|
+
options.push({ id: remembered, label: remembered });
|
|
6773
|
+
}
|
|
6774
|
+
return { options, source: options.length > 0 ? t('model.live') : t('model.configured') };
|
|
6775
|
+
}
|
|
5765
6776
|
/** /model: models and effort for the current provider only. */
|
|
5766
6777
|
async runModelCommand() {
|
|
5767
6778
|
const provider = this.currentProviderId();
|
|
6779
|
+
// The list is the whole family's; the question names the gateway, not the
|
|
6780
|
+
// protocol row the current request happens to use.
|
|
6781
|
+
const display = this.displayProviderId(provider);
|
|
5768
6782
|
const current = this.selectionRef?.current;
|
|
5769
6783
|
const loaded = await this.loadModelOptions(provider);
|
|
5770
6784
|
let modelOptions = loaded.options;
|
|
5771
6785
|
if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
|
|
5772
6786
|
modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
|
|
5773
6787
|
}
|
|
5774
|
-
const selected = await this.pickModelOption(modelOptions,
|
|
6788
|
+
const selected = await this.pickModelOption(modelOptions, display, loaded.source, current?.model);
|
|
5775
6789
|
if (selected === undefined)
|
|
5776
6790
|
return;
|
|
5777
6791
|
await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
|
|
@@ -5779,7 +6793,10 @@ export class SshTui {
|
|
|
5779
6793
|
/** /provider: pick a provider, then its model (remembered route pre-filled). */
|
|
5780
6794
|
async runProviderCommand() {
|
|
5781
6795
|
const providers = this.listSelectableProviders();
|
|
5782
|
-
|
|
6796
|
+
// The list holds family ids, so the highlight must be asked for in the same
|
|
6797
|
+
// vocabulary: a session on `command-code-messages` otherwise matched no row
|
|
6798
|
+
// and fell back to index 0, pre-selecting the wrong supplier.
|
|
6799
|
+
const current = this.displayProviderId(this.currentProviderId());
|
|
5783
6800
|
if (providers.length === 0) {
|
|
5784
6801
|
this.pushRow({ kind: 'error', text: t('provider.none') });
|
|
5785
6802
|
this.markDirty();
|
|
@@ -5812,17 +6829,56 @@ export class SshTui {
|
|
|
5812
6829
|
}
|
|
5813
6830
|
/** Persist a provider/model/effort choice and keep the subagent on the same family. */
|
|
5814
6831
|
rememberedRoute(provider) {
|
|
5815
|
-
const section = this.ctx
|
|
6832
|
+
const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
|
|
5816
6833
|
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
5817
6834
|
? parseRouteMemory(section.providers)
|
|
5818
6835
|
: {};
|
|
5819
6836
|
return rememberedRouteFor(memory, provider);
|
|
5820
6837
|
}
|
|
6838
|
+
/** The route this session is running right now, record-shaped. */
|
|
6839
|
+
sessionRouteRecord() {
|
|
6840
|
+
const current = this.selectionRef?.current;
|
|
6841
|
+
const subagent = this.subagentSelection.current;
|
|
6842
|
+
// Shaped by the launcher's own builder, so what the TUI reports and what the
|
|
6843
|
+
// launcher writes cannot drift apart.
|
|
6844
|
+
return sessionRouteInput({
|
|
6845
|
+
provider: current?.provider ?? this.currentProviderId(),
|
|
6846
|
+
model: current?.model ?? this.agent.options.model ?? '',
|
|
6847
|
+
...(current?.reasoningEffort === undefined ? {} : { reasoningEffort: String(current.reasoningEffort) }),
|
|
6848
|
+
subagent: {
|
|
6849
|
+
...(subagent.provider === undefined ? {} : { provider: subagent.provider }),
|
|
6850
|
+
model: subagent.model,
|
|
6851
|
+
...(subagent.reasoningEffort === undefined ? {} : { reasoningEffort: String(subagent.reasoningEffort) }),
|
|
6852
|
+
},
|
|
6853
|
+
});
|
|
6854
|
+
}
|
|
6855
|
+
/**
|
|
6856
|
+
* Tell the launcher which route this session has settled on, so resuming it
|
|
6857
|
+
* comes back on the same supplier — the subagent route included, which is what
|
|
6858
|
+
* makes a resumed cross-provider conversation reproducible.
|
|
6859
|
+
*
|
|
6860
|
+
* Reported when a route settles (`/model`, `/submodel`, `/setup`) and at the
|
|
6861
|
+
* start of every turn, which catches a preset or a settings edit moving the
|
|
6862
|
+
* route behind the TUI's back. The launcher owns the file; the TUI only says
|
|
6863
|
+
* what changed, and says nothing when nothing did.
|
|
6864
|
+
*/
|
|
6865
|
+
noteSessionRoute() {
|
|
6866
|
+
const route = this.sessionRouteRecord();
|
|
6867
|
+
if (route === undefined)
|
|
6868
|
+
return;
|
|
6869
|
+
if (sameSessionRoute(this.lastSessionRoute, route))
|
|
6870
|
+
return;
|
|
6871
|
+
this.lastSessionRoute = { ...route, updatedAt: Date.now() };
|
|
6872
|
+
this.onRouteSettled?.(route);
|
|
6873
|
+
}
|
|
5821
6874
|
async rememberRoute(selection) {
|
|
6875
|
+
// The session's record is kept even when the settings service is missing:
|
|
6876
|
+
// coming back to this conversation on the same supplier is the point of it.
|
|
6877
|
+
this.noteSessionRoute();
|
|
5822
6878
|
const settings = this.ctx.get('settings');
|
|
5823
6879
|
if (settings === undefined)
|
|
5824
6880
|
return;
|
|
5825
|
-
const section =
|
|
6881
|
+
const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
|
|
5826
6882
|
const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
|
|
5827
6883
|
? parseRouteMemory(section.providers)
|
|
5828
6884
|
: {};
|
|
@@ -5835,19 +6891,25 @@ export class SshTui {
|
|
|
5835
6891
|
]);
|
|
5836
6892
|
}
|
|
5837
6893
|
async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
|
|
5838
|
-
|
|
6894
|
+
// One gateway, one choice: the picker offers the family's merged list, and
|
|
6895
|
+
// the row that actually serves the model is resolved here so the selection
|
|
6896
|
+
// lands on a route whose protocol can send it. The family base id is what
|
|
6897
|
+
// the user sees and what the route memory remembers.
|
|
6898
|
+
const display = this.displayProviderId(provider);
|
|
6899
|
+
const target = this.familyOwnerRow(display, modelId, await this.advertisedFor(display, modelId));
|
|
6900
|
+
if (!(await this.ensureProviderModelConfigured(target, modelId)))
|
|
5839
6901
|
return;
|
|
5840
6902
|
const llm = this.ctx.get('llm');
|
|
5841
6903
|
const current = this.selectionRef?.current;
|
|
5842
6904
|
let effortOptions = [];
|
|
5843
6905
|
try {
|
|
5844
|
-
const info = await llm?.resolveModelInfo(
|
|
6906
|
+
const info = await llm?.resolveModelInfo(target, modelId);
|
|
5845
6907
|
effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
|
|
5846
6908
|
}
|
|
5847
6909
|
catch {
|
|
5848
6910
|
effortOptions = [];
|
|
5849
6911
|
}
|
|
5850
|
-
if (effortOptions.length === 0 && providerUsesLocalOAuth(
|
|
6912
|
+
if (effortOptions.length === 0 && providerUsesLocalOAuth(target)) {
|
|
5851
6913
|
effortOptions = localOAuthEffortChoices(modelId);
|
|
5852
6914
|
}
|
|
5853
6915
|
const isUndeclared = effortOptions.length === 0;
|
|
@@ -5864,8 +6926,8 @@ export class SshTui {
|
|
|
5864
6926
|
desc: undefined,
|
|
5865
6927
|
})),
|
|
5866
6928
|
];
|
|
5867
|
-
const rememberedEffort = this.rememberedRoute(
|
|
5868
|
-
const currentEffort = current?.provider ===
|
|
6929
|
+
const rememberedEffort = this.rememberedRoute(display)?.reasoningEffort ?? preferredEffort ?? '';
|
|
6930
|
+
const currentEffort = current?.provider === target
|
|
5869
6931
|
? String(current?.reasoningEffort ?? '')
|
|
5870
6932
|
: rememberedEffort;
|
|
5871
6933
|
const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
|
|
@@ -5881,9 +6943,9 @@ export class SshTui {
|
|
|
5881
6943
|
}, 0, 1, currentIndex);
|
|
5882
6944
|
const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
5883
6945
|
if (isUndeclared && effort !== undefined)
|
|
5884
|
-
await this.declareReasoningEffort(
|
|
6946
|
+
await this.declareReasoningEffort(target, modelId, effort);
|
|
5885
6947
|
const next = {
|
|
5886
|
-
provider,
|
|
6948
|
+
provider: target,
|
|
5887
6949
|
model: modelId,
|
|
5888
6950
|
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
5889
6951
|
};
|
|
@@ -5891,23 +6953,23 @@ export class SshTui {
|
|
|
5891
6953
|
this.selectionRef.current = next;
|
|
5892
6954
|
this.onSelectionChanged?.(next);
|
|
5893
6955
|
await this.persistDefaultSelection(next);
|
|
5894
|
-
await this.rememberRoute(next);
|
|
5895
|
-
const kind = describeProviderRoute(
|
|
6956
|
+
await this.rememberRoute({ ...next, provider: display });
|
|
6957
|
+
const kind = describeProviderRoute(display);
|
|
5896
6958
|
const effortText = effort ?? t('effort.defaultShort');
|
|
5897
6959
|
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
5898
6960
|
this.pushRow({
|
|
5899
6961
|
kind: 'system',
|
|
5900
|
-
text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
|
|
6962
|
+
text: t('effort.switchedModel', { kind: kind.kind, provider: display, model: modelId, effort: effortText, note }),
|
|
5901
6963
|
});
|
|
5902
6964
|
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5903
6965
|
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
5904
|
-
if (previousProvider !==
|
|
5905
|
-
await this.syncSubagentToProvider(
|
|
5906
|
-
this.clearQuotaForProvider(
|
|
6966
|
+
if (this.displayProviderId(previousProvider) !== display) {
|
|
6967
|
+
await this.syncSubagentToProvider(display, listedIds, true);
|
|
6968
|
+
this.clearQuotaForProvider(target);
|
|
5907
6969
|
void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
|
|
5908
6970
|
}
|
|
5909
6971
|
else {
|
|
5910
|
-
await this.syncSubagentToProvider(
|
|
6972
|
+
await this.syncSubagentToProvider(display, listedIds);
|
|
5911
6973
|
}
|
|
5912
6974
|
this.markDirty();
|
|
5913
6975
|
}
|
|
@@ -5932,7 +6994,7 @@ export class SshTui {
|
|
|
5932
6994
|
}
|
|
5933
6995
|
if (settings !== undefined) {
|
|
5934
6996
|
try {
|
|
5935
|
-
await settings.replace(
|
|
6997
|
+
await settings.replace(AGENT_DEFAULT_MODEL_NS, {
|
|
5936
6998
|
provider: next.provider,
|
|
5937
6999
|
model: next.model,
|
|
5938
7000
|
...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
|
|
@@ -5963,11 +7025,23 @@ export class SshTui {
|
|
|
5963
7025
|
* When the parent provider changes (OAuth or API key), keep the subagent
|
|
5964
7026
|
* on a same-family model. An explicit leftover DeepSeek flash id after
|
|
5965
7027
|
* switching to xAI is treated as stale.
|
|
7028
|
+
*
|
|
7029
|
+
* A `/submodel` pin is the user's explicit route, so a parent switch asks
|
|
7030
|
+
* before moving the children: dropping the pin silently spends on a route
|
|
7031
|
+
* they pinned away from, and keeping it silently leaves the children on the
|
|
7032
|
+
* old provider.
|
|
5966
7033
|
*/
|
|
5967
7034
|
async syncSubagentToProvider(provider, listed = [], force = false) {
|
|
7035
|
+
// Children follow the family the user sees, not the protocol row the parent
|
|
7036
|
+
// request happens to use.
|
|
7037
|
+
provider = this.displayProviderId(provider);
|
|
5968
7038
|
const current = this.subagentSelection.current;
|
|
5969
|
-
if (
|
|
5970
|
-
|
|
7039
|
+
if (current.provider !== undefined) {
|
|
7040
|
+
if (!force)
|
|
7041
|
+
return;
|
|
7042
|
+
if (await this.confirmKeepSubagentPin(provider, current))
|
|
7043
|
+
return;
|
|
7044
|
+
}
|
|
5971
7045
|
if (!force && subagentModelMatchesProvider(provider, current.model, listed))
|
|
5972
7046
|
return;
|
|
5973
7047
|
let catalog = [...listed];
|
|
@@ -5993,6 +7067,31 @@ export class SshTui {
|
|
|
5993
7067
|
text: t('sub.followed', { provider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
|
|
5994
7068
|
});
|
|
5995
7069
|
}
|
|
7070
|
+
/**
|
|
7071
|
+
* Ask whether a `/submodel` pin survives a parent provider change.
|
|
7072
|
+
*
|
|
7073
|
+
* Returns true when the pin should be kept. Cancelling the dialog keeps it
|
|
7074
|
+
* too: the pin is the user's explicit choice, so only an explicit answer
|
|
7075
|
+
* moves the children onto the new parent route.
|
|
7076
|
+
*/
|
|
7077
|
+
async confirmKeepSubagentPin(provider, current) {
|
|
7078
|
+
const pinned = this.displayProviderId(current.provider ?? '');
|
|
7079
|
+
const keepLabel = t('sub.followKeepLabel', { provider: pinned });
|
|
7080
|
+
const followLabel = t('sub.followSwitchLabel', { provider });
|
|
7081
|
+
const answer = await this.askQuestion({
|
|
7082
|
+
id: 'submodel-follow-pick',
|
|
7083
|
+
question: t('sub.followAsk', { pinned, provider, model: current.model }),
|
|
7084
|
+
options: [
|
|
7085
|
+
{ label: keepLabel, description: t('sub.followKeepHint') },
|
|
7086
|
+
{ label: followLabel, description: t('sub.followSwitchHint', { provider }) },
|
|
7087
|
+
],
|
|
7088
|
+
}, 0, 1, 0);
|
|
7089
|
+
if (answer.selected[0] === followLabel)
|
|
7090
|
+
return false;
|
|
7091
|
+
this.pushRow({ kind: 'system', text: t('sub.pinKept', { pinned, parent: provider }) });
|
|
7092
|
+
this.markDirty();
|
|
7093
|
+
return true;
|
|
7094
|
+
}
|
|
5996
7095
|
clearQuotaForProvider(provider) {
|
|
5997
7096
|
const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
|
|
5998
7097
|
const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
|
|
@@ -6010,6 +7109,13 @@ export class SshTui {
|
|
|
6010
7109
|
/** Persist one subagent selection and publish it to the live request waterfall. */
|
|
6011
7110
|
async saveSubagentSelection(next) {
|
|
6012
7111
|
this.subagentSelection.current = next;
|
|
7112
|
+
// An explicit change makes the settings the source again: the next time
|
|
7113
|
+
// this session is resumed, the record — not a stale in-memory override —
|
|
7114
|
+
// decides, and the settings watcher may update the ref again.
|
|
7115
|
+
this.subagentSelection.source = 'settings';
|
|
7116
|
+
// The session record keeps the subagent route too, so a resumed
|
|
7117
|
+
// cross-provider conversation comes back on the same children.
|
|
7118
|
+
this.noteSessionRoute();
|
|
6013
7119
|
const settings = this.ctx.get('settings');
|
|
6014
7120
|
if (settings === undefined) {
|
|
6015
7121
|
this.pushRow({ kind: 'error', text: t('sub.settingsMissing') });
|
|
@@ -6021,6 +7127,9 @@ export class SshTui {
|
|
|
6021
7127
|
}
|
|
6022
7128
|
/** Resolve the picker model list for one provider (endpoint first, then catalog). */
|
|
6023
7129
|
async subagentModelOptions(provider) {
|
|
7130
|
+
const family = this.providerFamilyRows(provider);
|
|
7131
|
+
if (family.length > 1)
|
|
7132
|
+
return this.loadFamilyModelOptions(family[0] ?? provider, family);
|
|
6024
7133
|
const llm = this.ctx.get('llm');
|
|
6025
7134
|
let options = [];
|
|
6026
7135
|
let source = t('model.configured');
|
|
@@ -6066,11 +7175,10 @@ export class SshTui {
|
|
|
6066
7175
|
}
|
|
6067
7176
|
/** /submodel: pick (or set) the model subagent children use. */
|
|
6068
7177
|
async runSubmodelCommand(arg) {
|
|
6069
|
-
const provider = this.effectiveSubagentProvider();
|
|
6070
7178
|
const current = this.subagentSelection.current;
|
|
6071
7179
|
const direct = arg.trim();
|
|
6072
7180
|
if (direct.toLowerCase() === 'reset' || direct === '跟随' || direct === '默认') {
|
|
6073
|
-
const parentProvider = this.currentProviderId();
|
|
7181
|
+
const parentProvider = this.displayProviderId(this.currentProviderId());
|
|
6074
7182
|
const nextModel = defaultSubagentModelForProvider(parentProvider, [], this.selectionRef?.current?.model ?? this.agent.options.model);
|
|
6075
7183
|
const persisted = await this.saveSubagentSelection({
|
|
6076
7184
|
model: nextModel,
|
|
@@ -6083,8 +7191,42 @@ export class SshTui {
|
|
|
6083
7191
|
this.markDirty();
|
|
6084
7192
|
return;
|
|
6085
7193
|
}
|
|
7194
|
+
const slash = direct.indexOf('/');
|
|
7195
|
+
if (slash > 0) {
|
|
7196
|
+
const providerId = direct.slice(0, slash).trim();
|
|
7197
|
+
const modelId = direct.slice(slash + 1).trim();
|
|
7198
|
+
if (providerId !== '' && modelId !== '') {
|
|
7199
|
+
await this.commitSubagentRoute(providerId, modelId, { pinProvider: true });
|
|
7200
|
+
return;
|
|
7201
|
+
}
|
|
7202
|
+
}
|
|
7203
|
+
let provider = this.displayProviderId(this.effectiveSubagentProvider());
|
|
6086
7204
|
let selectedId = direct;
|
|
6087
7205
|
if (selectedId === '') {
|
|
7206
|
+
const parentProvider = this.displayProviderId(this.currentProviderId());
|
|
7207
|
+
const providers = this.listSelectableProviders();
|
|
7208
|
+
const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
|
|
7209
|
+
const pinnedProvider = this.displayProviderId(current.provider ?? '');
|
|
7210
|
+
const pickedAnswer = await this.askQuestion({
|
|
7211
|
+
id: 'submodel-provider-pick',
|
|
7212
|
+
question: t('sub.pickProvider'),
|
|
7213
|
+
options: providers.map(option => ({
|
|
7214
|
+
label: option.label,
|
|
7215
|
+
// The description says what picking the row does. Picking the parent's
|
|
7216
|
+
// provider follows it — that is the one explicit way to unpin without
|
|
7217
|
+
// `/submodel reset` — so the parent row must not read "currently
|
|
7218
|
+
// pinned" just because the pin happens to name it.
|
|
7219
|
+
description: option.id === parentProvider
|
|
7220
|
+
? t('sub.providerFollowsParent')
|
|
7221
|
+
: option.id === pinnedProvider
|
|
7222
|
+
? t('sub.providerPinned')
|
|
7223
|
+
: describeProviderRoute(option.id).kind,
|
|
7224
|
+
})),
|
|
7225
|
+
}, 0, 1, currentIndex);
|
|
7226
|
+
const pickedProvider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
|
|
7227
|
+
if (pickedProvider === undefined)
|
|
7228
|
+
return;
|
|
7229
|
+
provider = pickedProvider;
|
|
6088
7230
|
const { options, source } = await this.subagentModelOptions(provider);
|
|
6089
7231
|
if (options.length === 0) {
|
|
6090
7232
|
options.push({ id: current.model, label: current.model });
|
|
@@ -6094,14 +7236,30 @@ export class SshTui {
|
|
|
6094
7236
|
return;
|
|
6095
7237
|
selectedId = selected.id;
|
|
6096
7238
|
}
|
|
6097
|
-
|
|
7239
|
+
await this.commitSubagentRoute(provider, selectedId, {
|
|
7240
|
+
pinProvider: provider !== this.displayProviderId(this.currentProviderId()),
|
|
7241
|
+
});
|
|
7242
|
+
}
|
|
7243
|
+
/** Persist one subagent provider/model pair and say whether it follows or is pinned. */
|
|
7244
|
+
async commitSubagentRoute(provider, modelId, options) {
|
|
7245
|
+
// The picker lists one row per gateway family; the model decides which
|
|
7246
|
+
// protocol row underneath actually serves it.
|
|
7247
|
+
const display = this.displayProviderId(provider);
|
|
7248
|
+
const target = this.familyOwnerRow(display, modelId, await this.advertisedFor(display, modelId));
|
|
7249
|
+
if (!(await this.ensureProviderModelConfigured(target, modelId)))
|
|
6098
7250
|
return;
|
|
6099
|
-
const
|
|
7251
|
+
const effort = this.subagentSelection.current.reasoningEffort;
|
|
7252
|
+
const next = {
|
|
7253
|
+
...(options.pinProvider ? { provider: target } : {}),
|
|
7254
|
+
model: modelId,
|
|
7255
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
7256
|
+
};
|
|
7257
|
+
const persisted = await this.saveSubagentSelection(next);
|
|
6100
7258
|
this.pushRow({
|
|
6101
7259
|
kind: 'system',
|
|
6102
|
-
text: (
|
|
6103
|
-
? t('sub.
|
|
6104
|
-
: t('sub.
|
|
7260
|
+
text: (options.pinProvider
|
|
7261
|
+
? t('sub.modelPinned', { model: modelId, provider: display })
|
|
7262
|
+
: t('sub.modelFollow', { model: modelId, provider: display }))
|
|
6105
7263
|
+ (persisted ? '' : t('sub.sessionOnly')),
|
|
6106
7264
|
});
|
|
6107
7265
|
this.markDirty();
|
|
@@ -6109,6 +7267,7 @@ export class SshTui {
|
|
|
6109
7267
|
/** /effort: pick or set the reasoning effort for the current model. */
|
|
6110
7268
|
async runEffortCommand(arg) {
|
|
6111
7269
|
const provider = this.currentProviderId();
|
|
7270
|
+
const display = this.displayProviderId(provider);
|
|
6112
7271
|
const current = this.selectionRef?.current;
|
|
6113
7272
|
if (current === undefined || current.model === undefined) {
|
|
6114
7273
|
this.pushRow({ kind: 'error', text: t('effort.noModel') });
|
|
@@ -6165,8 +7324,8 @@ export class SshTui {
|
|
|
6165
7324
|
const answer = await this.askQuestion({
|
|
6166
7325
|
id: 'effort-pick',
|
|
6167
7326
|
question: isUndeclared
|
|
6168
|
-
? t('effort.pickCurrentUndeclared', { provider, model: modelId })
|
|
6169
|
-
: t('effort.pickCurrent', { provider, model: modelId }),
|
|
7327
|
+
? t('effort.pickCurrentUndeclared', { provider: display, model: modelId })
|
|
7328
|
+
: t('effort.pickCurrent', { provider: display, model: modelId }),
|
|
6170
7329
|
options: choices.map(c => ({
|
|
6171
7330
|
label: c.label,
|
|
6172
7331
|
description: c.id === currentEffort ? t('disconnect.current') : c.desc,
|
|
@@ -6435,7 +7594,7 @@ export class SshTui {
|
|
|
6435
7594
|
}
|
|
6436
7595
|
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
6437
7596
|
async runModeCommand(arg = '') {
|
|
6438
|
-
const agentPresets = this.
|
|
7597
|
+
const agentPresets = this.presetService();
|
|
6439
7598
|
const direct = arg.trim().toLowerCase();
|
|
6440
7599
|
if (direct === 'fix' || direct === 'repair') {
|
|
6441
7600
|
await this.repairRoster();
|
|
@@ -6450,13 +7609,7 @@ export class SshTui {
|
|
|
6450
7609
|
const profile = profileFromArgv();
|
|
6451
7610
|
this.pushRow({
|
|
6452
7611
|
kind: 'error',
|
|
6453
|
-
text:
|
|
6454
|
-
t('mode.missingService'),
|
|
6455
|
-
t('mode.missingServiceHint', {
|
|
6456
|
-
profile,
|
|
6457
|
-
patch: rosterPatchPath(resolveDshHome(), profile),
|
|
6458
|
-
}),
|
|
6459
|
-
].join('\n'),
|
|
7612
|
+
text: this.missingPresetService(t('mode.missingService')),
|
|
6460
7613
|
});
|
|
6461
7614
|
this.markDirty();
|
|
6462
7615
|
return;
|
|
@@ -6516,6 +7669,13 @@ export class SshTui {
|
|
|
6516
7669
|
const selectedName = option.label;
|
|
6517
7670
|
const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
|
|
6518
7671
|
if (!hasWork) {
|
|
7672
|
+
// Feature-detected: a host whose registry predates `recompose` still
|
|
7673
|
+
// lists presets, and switching then needs a restarted session.
|
|
7674
|
+
if (typeof agentPresets.recompose !== 'function') {
|
|
7675
|
+
this.pushRow({ kind: 'error', text: t('mode.noRecompose', { id: selected.id }) });
|
|
7676
|
+
this.markDirty();
|
|
7677
|
+
return;
|
|
7678
|
+
}
|
|
6519
7679
|
await agentPresets.recompose(this.agent.ctx, selected.id);
|
|
6520
7680
|
this.presetId = selected.id;
|
|
6521
7681
|
this.presetName = selectedName;
|
|
@@ -6539,13 +7699,40 @@ export class SshTui {
|
|
|
6539
7699
|
*/
|
|
6540
7700
|
/** Re-read the roster fact this process was composed with (cheap, no IO). */
|
|
6541
7701
|
refreshRosterHealth() {
|
|
6542
|
-
|
|
7702
|
+
// 0.1.7 has no roster to be missing: what a terminal profile owns there is
|
|
7703
|
+
// the agent-plane rows, so the chip reports those instead.
|
|
7704
|
+
this.rosterMissing = this.settingsGeneration === 'forms'
|
|
7705
|
+
? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
|
|
7706
|
+
: this.ctx.get('agentPresets') === undefined;
|
|
7707
|
+
}
|
|
7708
|
+
/** Which settings protocol this host speaks; decides what "missing" means. */
|
|
7709
|
+
get settingsGeneration() {
|
|
7710
|
+
return hostSettingsGeneration(this.ctx);
|
|
7711
|
+
}
|
|
7712
|
+
/**
|
|
7713
|
+
* The two lines `/mode` and `/preset` print when the preset service is absent.
|
|
7714
|
+
*
|
|
7715
|
+
* The reason differs by host line: 0.1.5 lost a roster the profile can mount,
|
|
7716
|
+
* while 0.1.7 composes the agent process-wide and has no terminal presets at
|
|
7717
|
+
* all — the repair there is the agent-plane rows, not a roster.
|
|
7718
|
+
*/
|
|
7719
|
+
missingPresetService(first) {
|
|
7720
|
+
const profile = profileFromArgv();
|
|
7721
|
+
const patch = rosterPatchPath(resolveDshHome(), profile);
|
|
7722
|
+
return this.settingsGeneration === 'forms'
|
|
7723
|
+
? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
|
|
7724
|
+
: [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
|
|
6543
7725
|
}
|
|
6544
7726
|
async repairRoster() {
|
|
6545
7727
|
const profile = profileFromArgv();
|
|
6546
7728
|
const patch = rosterPatchPath(resolveDshHome(), profile);
|
|
6547
7729
|
try {
|
|
6548
|
-
|
|
7730
|
+
// Which rows are "the rows" is a host-line fact: 0.1.7 composes the agent
|
|
7731
|
+
// process-wide and owns the agent-plane rows instead of the roster. Both
|
|
7732
|
+
// the list and the header come from the same generation `/doctor` uses,
|
|
7733
|
+
// or this writes rows the running host cannot resolve.
|
|
7734
|
+
const generation = this.settingsGeneration;
|
|
7735
|
+
const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
|
|
6549
7736
|
this.refreshRosterHealth();
|
|
6550
7737
|
this.pushRow({
|
|
6551
7738
|
kind: 'system',
|
|
@@ -6666,7 +7853,7 @@ export class SshTui {
|
|
|
6666
7853
|
return;
|
|
6667
7854
|
}
|
|
6668
7855
|
const provider = this.currentProviderId();
|
|
6669
|
-
const llmPiAi = this.ctx
|
|
7856
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
6670
7857
|
const source = openCodeSourceFor(provider, llmPiAi);
|
|
6671
7858
|
if (source?.flavor === 'zen') {
|
|
6672
7859
|
this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
|
|
@@ -6728,7 +7915,7 @@ export class SshTui {
|
|
|
6728
7915
|
* neither gets nothing.
|
|
6729
7916
|
*/
|
|
6730
7917
|
hasQuotaSurface(provider) {
|
|
6731
|
-
const llmPiAi = this.ctx
|
|
7918
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
6732
7919
|
return providerHasQuotaSurface(provider, llmPiAi);
|
|
6733
7920
|
}
|
|
6734
7921
|
async refreshQuota(options) {
|
|
@@ -6787,7 +7974,7 @@ export class SshTui {
|
|
|
6787
7974
|
const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
|
|
6788
7975
|
if (apiKey === undefined)
|
|
6789
7976
|
throw new Error(t('usage.noDeepseekKey'));
|
|
6790
|
-
const section = this.ctx
|
|
7977
|
+
const section = readSettingsSection(this.ctx, settingsNamespace('llm-deepseek'));
|
|
6791
7978
|
const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
|
|
6792
7979
|
? section.baseURL.trim()
|
|
6793
7980
|
: (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
|
|
@@ -6856,7 +8043,7 @@ export class SshTui {
|
|
|
6856
8043
|
return parseSuperGrokBilling(payload);
|
|
6857
8044
|
}
|
|
6858
8045
|
}
|
|
6859
|
-
const llmPiAi = this.ctx
|
|
8046
|
+
const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
|
|
6860
8047
|
const commandCode = commandCodeSourceFor(provider, llmPiAi);
|
|
6861
8048
|
if (commandCode !== null)
|
|
6862
8049
|
return this.fetchCommandCodeQuota(commandCode);
|
|
@@ -6940,6 +8127,9 @@ export class SshTui {
|
|
|
6940
8127
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
|
|
6941
8128
|
return;
|
|
6942
8129
|
}
|
|
8130
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(-1)) {
|
|
8131
|
+
return;
|
|
8132
|
+
}
|
|
6943
8133
|
else if (this.suggestionsVisible()) {
|
|
6944
8134
|
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
6945
8135
|
this.markDirty();
|
|
@@ -6961,6 +8151,9 @@ export class SshTui {
|
|
|
6961
8151
|
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
|
|
6962
8152
|
return;
|
|
6963
8153
|
}
|
|
8154
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(1)) {
|
|
8155
|
+
return;
|
|
8156
|
+
}
|
|
6964
8157
|
else if (this.suggestionsVisible()) {
|
|
6965
8158
|
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
6966
8159
|
this.markDirty();
|
|
@@ -7416,8 +8609,8 @@ export class SshTui {
|
|
|
7416
8609
|
const templates = providerTemplates();
|
|
7417
8610
|
const templateEntries = [
|
|
7418
8611
|
{ 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: '
|
|
8612
|
+
{ key: 'template:opencode-go', label: templates['opencode-go'].label, detail: t('onboard.protocolAuto') },
|
|
8613
|
+
{ key: 'template:command-code', label: templates['command-code'].label, detail: t('onboard.protocolAuto') },
|
|
7421
8614
|
{ key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
|
|
7422
8615
|
{ key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
|
|
7423
8616
|
{ key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
|
|
@@ -7464,6 +8657,108 @@ export class SshTui {
|
|
|
7464
8657
|
}
|
|
7465
8658
|
return sized;
|
|
7466
8659
|
}
|
|
8660
|
+
/**
|
|
8661
|
+
* Leave the models step, after one best-effort trip to the gateway for what
|
|
8662
|
+
* the picks are still missing: the context window / output cap of every model
|
|
8663
|
+
* the listing has not described yet, and — for a gateway that spreads over
|
|
8664
|
+
* several protocols — the endpoints each picked model answers.
|
|
8665
|
+
*
|
|
8666
|
+
* `Ctrl+F` stays as it was, but it is no longer the only way to learn these:
|
|
8667
|
+
* a model typed by hand would otherwise be saved with no capacity at all, and
|
|
8668
|
+
* with no endpoint data the split had nothing to file it by.
|
|
8669
|
+
*/
|
|
8670
|
+
async finishModelsStep(state) {
|
|
8671
|
+
if (this.onboarding !== state || !isModelsStep(state.step))
|
|
8672
|
+
return;
|
|
8673
|
+
if (this.finishingModels.has(state))
|
|
8674
|
+
return;
|
|
8675
|
+
this.finishingModels.add(state);
|
|
8676
|
+
try {
|
|
8677
|
+
const previousStatus = this.status;
|
|
8678
|
+
if (this.onboardingWantsEndpoint(state)) {
|
|
8679
|
+
const fetching = t('onboard.fetchingModels');
|
|
8680
|
+
this.status = fetching;
|
|
8681
|
+
this.markDirty();
|
|
8682
|
+
await this.enrichOnboardingModels(state);
|
|
8683
|
+
// Only take back the line this step put up: a turn that started while
|
|
8684
|
+
// the listing was in flight owns the status now, and restoring the
|
|
8685
|
+
// pre-fetch value would erase "turn 7 running" behind its back.
|
|
8686
|
+
if (this.status === fetching)
|
|
8687
|
+
this.status = previousStatus;
|
|
8688
|
+
}
|
|
8689
|
+
// The wizard may have been cancelled or reset while the fetch was in flight.
|
|
8690
|
+
if (this.onboarding !== state || !isModelsStep(state.step))
|
|
8691
|
+
return;
|
|
8692
|
+
// Size the picks before the confirm step so the route default the wizard
|
|
8693
|
+
// persists is derived, not guessed: the listing first, the installed
|
|
8694
|
+
// catalog second. Only when some pick stayed unsized does the route
|
|
8695
|
+
// default matter, and only then is a step shown for it.
|
|
8696
|
+
const sized = this.sizeOnboardingModels(state);
|
|
8697
|
+
const unsized = state.models.filter(id => !sized.has(id));
|
|
8698
|
+
if (unsized.length === 0) {
|
|
8699
|
+
state.step = 'confirm';
|
|
8700
|
+
}
|
|
8701
|
+
else {
|
|
8702
|
+
state.routeContextWindow = suggestedRouteContextWindow(sized.values())
|
|
8703
|
+
?? HARNESS_DEFAULT_CONTEXT_WINDOW;
|
|
8704
|
+
state.step = 'context';
|
|
8705
|
+
}
|
|
8706
|
+
this.input = '';
|
|
8707
|
+
this.cursor = 0;
|
|
8708
|
+
this.markDirty();
|
|
8709
|
+
}
|
|
8710
|
+
finally {
|
|
8711
|
+
this.finishingModels.delete(state);
|
|
8712
|
+
}
|
|
8713
|
+
}
|
|
8714
|
+
/** Whether leaving the models step has anything left to ask the gateway for. */
|
|
8715
|
+
onboardingWantsEndpoint(state) {
|
|
8716
|
+
if (onboardTemplate(state).protocols !== undefined && state.modelEndpoints === undefined)
|
|
8717
|
+
return true;
|
|
8718
|
+
return state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
|
|
8719
|
+
}
|
|
8720
|
+
/** Fill the picks' capacities and endpoints from the gateway; never fatal. */
|
|
8721
|
+
async enrichOnboardingModels(state) {
|
|
8722
|
+
const template = onboardTemplate(state);
|
|
8723
|
+
const baseURL = state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl;
|
|
8724
|
+
if (baseURL === '')
|
|
8725
|
+
return;
|
|
8726
|
+
const wantsEndpoints = template.protocols !== undefined && state.modelEndpoints === undefined;
|
|
8727
|
+
const wantsCapacity = state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
|
|
8728
|
+
const llm = this.ctx.get('llm');
|
|
8729
|
+
if (wantsCapacity && llm !== undefined) {
|
|
8730
|
+
try {
|
|
8731
|
+
const discovered = await discoverProviderModels(llm, {
|
|
8732
|
+
baseURL,
|
|
8733
|
+
...(template.api === undefined ? {} : { api: template.api }),
|
|
8734
|
+
...(state.key === '' ? {} : { apiKey: state.key }),
|
|
8735
|
+
}, AbortSignal.timeout(8_000));
|
|
8736
|
+
state.modelCapacity ??= new Map();
|
|
8737
|
+
for (const model of discovered) {
|
|
8738
|
+
if (!state.models.includes(model.id))
|
|
8739
|
+
continue;
|
|
8740
|
+
const capacity = { ...(state.modelCapacity.get(model.id) ?? {}) };
|
|
8741
|
+
if (capacity.contextWindow === undefined && model.contextWindow !== undefined) {
|
|
8742
|
+
capacity.contextWindow = model.contextWindow;
|
|
8743
|
+
}
|
|
8744
|
+
if (capacity.maxTokens === undefined && model.maxTokens !== undefined) {
|
|
8745
|
+
capacity.maxTokens = model.maxTokens;
|
|
8746
|
+
}
|
|
8747
|
+
if (Object.keys(capacity).length > 0)
|
|
8748
|
+
state.modelCapacity.set(model.id, capacity);
|
|
8749
|
+
}
|
|
8750
|
+
}
|
|
8751
|
+
catch {
|
|
8752
|
+
// The listing is an enrichment, not a gate: the installed catalog and
|
|
8753
|
+
// the route default still cover a gateway that cannot be listed.
|
|
8754
|
+
}
|
|
8755
|
+
}
|
|
8756
|
+
if (wantsEndpoints) {
|
|
8757
|
+
const endpoints = await fetchModelEndpoints(baseURL, state.key);
|
|
8758
|
+
if (endpoints !== undefined)
|
|
8759
|
+
state.modelEndpoints = endpoints;
|
|
8760
|
+
}
|
|
8761
|
+
}
|
|
7467
8762
|
handleOnboardingChar(text) {
|
|
7468
8763
|
const state = this.onboarding;
|
|
7469
8764
|
if (state === undefined)
|
|
@@ -7523,7 +8818,77 @@ export class SshTui {
|
|
|
7523
8818
|
case 'base-url':
|
|
7524
8819
|
case 'key':
|
|
7525
8820
|
case 'models':
|
|
8821
|
+
case 'models-pick':
|
|
8822
|
+
case 'model-default':
|
|
7526
8823
|
case 'context': {
|
|
8824
|
+
if (state.step === 'models-pick') {
|
|
8825
|
+
// The step is already being left (the gateway listing is in flight):
|
|
8826
|
+
// the pick is decided, so a stray key must not reopen it.
|
|
8827
|
+
if (this.finishingModels.has(state))
|
|
8828
|
+
return;
|
|
8829
|
+
const total = state.modelCandidates?.length ?? 0;
|
|
8830
|
+
if (total === 0) {
|
|
8831
|
+
state.step = 'models';
|
|
8832
|
+
this.markDirty();
|
|
8833
|
+
return;
|
|
8834
|
+
}
|
|
8835
|
+
const checked = state.modelChecked ?? (state.modelChecked = new Set());
|
|
8836
|
+
if (text === '\r' || text === '\n') {
|
|
8837
|
+
const picked = this.checkedOnboardingModels(state);
|
|
8838
|
+
if (picked.length === 0) {
|
|
8839
|
+
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
8840
|
+
this.markDirty();
|
|
8841
|
+
return;
|
|
8842
|
+
}
|
|
8843
|
+
state.models = picked;
|
|
8844
|
+
state.defaultModel = picked[0];
|
|
8845
|
+
if (picked.length > 1) {
|
|
8846
|
+
state.step = 'model-default';
|
|
8847
|
+
state.modelCursor = 0;
|
|
8848
|
+
this.markDirty();
|
|
8849
|
+
return;
|
|
8850
|
+
}
|
|
8851
|
+
this.advanceOnboarding();
|
|
8852
|
+
return;
|
|
8853
|
+
}
|
|
8854
|
+
if (text === ' ' || text === ' ') {
|
|
8855
|
+
const index = state.modelCursor ?? 0;
|
|
8856
|
+
if (checked.has(index))
|
|
8857
|
+
checked.delete(index);
|
|
8858
|
+
else
|
|
8859
|
+
checked.add(index);
|
|
8860
|
+
this.markDirty();
|
|
8861
|
+
return;
|
|
8862
|
+
}
|
|
8863
|
+
const hotkey = questionOptionIndex(text, total);
|
|
8864
|
+
if (hotkey !== undefined) {
|
|
8865
|
+
state.modelCursor = hotkey;
|
|
8866
|
+
if (checked.has(hotkey))
|
|
8867
|
+
checked.delete(hotkey);
|
|
8868
|
+
else
|
|
8869
|
+
checked.add(hotkey);
|
|
8870
|
+
this.markDirty();
|
|
8871
|
+
}
|
|
8872
|
+
return;
|
|
8873
|
+
}
|
|
8874
|
+
if (state.step === 'model-default') {
|
|
8875
|
+
if (this.finishingModels.has(state))
|
|
8876
|
+
return;
|
|
8877
|
+
const picked = this.checkedOnboardingModels(state);
|
|
8878
|
+
if (text === '\r' || text === '\n') {
|
|
8879
|
+
const chosen = picked[Math.min(state.modelCursor ?? 0, Math.max(0, picked.length - 1))];
|
|
8880
|
+
if (chosen !== undefined)
|
|
8881
|
+
state.defaultModel = chosen;
|
|
8882
|
+
this.advanceOnboarding();
|
|
8883
|
+
return;
|
|
8884
|
+
}
|
|
8885
|
+
const hotkey = questionOptionIndex(text, picked.length);
|
|
8886
|
+
if (hotkey !== undefined) {
|
|
8887
|
+
state.modelCursor = hotkey;
|
|
8888
|
+
this.markDirty();
|
|
8889
|
+
}
|
|
8890
|
+
return;
|
|
8891
|
+
}
|
|
7527
8892
|
if (state.step === 'models' && text === '\x06') {
|
|
7528
8893
|
void this.fetchOnboardingModels();
|
|
7529
8894
|
return;
|
|
@@ -7549,19 +8914,26 @@ export class SshTui {
|
|
|
7549
8914
|
state.key = value;
|
|
7550
8915
|
}
|
|
7551
8916
|
else if (state.step === 'models') {
|
|
7552
|
-
|
|
7553
|
-
//
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
:
|
|
8917
|
+
// Leaving the step is already under way (the gateway listing is in
|
|
8918
|
+
// flight): a second Enter must not restart it or reopen the picker.
|
|
8919
|
+
if (this.finishingModels.has(state))
|
|
8920
|
+
return;
|
|
8921
|
+
if (value === '') {
|
|
8922
|
+
// The default answer is a choice, not "take everything the listing
|
|
8923
|
+
// returned": the picker shows the models and lets the user pick
|
|
8924
|
+
// which to configure, then which one to run. A typed list stays
|
|
8925
|
+
// the power-user path and skips both questions.
|
|
8926
|
+
this.openOnboardingModelPicker(state);
|
|
8927
|
+
return;
|
|
8928
|
+
}
|
|
8929
|
+
const parsed = value.split(/[\s,,]+/u).filter(Boolean);
|
|
7559
8930
|
if (parsed.length === 0) {
|
|
7560
8931
|
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
7561
8932
|
this.markDirty();
|
|
7562
8933
|
return;
|
|
7563
8934
|
}
|
|
7564
8935
|
state.models = parsed;
|
|
8936
|
+
state.defaultModel = parsed[0];
|
|
7565
8937
|
}
|
|
7566
8938
|
else if (state.step === 'context') {
|
|
7567
8939
|
// Enter keeps the pre-filled value; a typed number overrides it.
|
|
@@ -7608,6 +8980,12 @@ export class SshTui {
|
|
|
7608
8980
|
state.baseUrl = '';
|
|
7609
8981
|
state.key = '';
|
|
7610
8982
|
state.models = [];
|
|
8983
|
+
// A restart must not inherit the previous run's picker: a stale
|
|
8984
|
+
// `modelChecked` would pre-tick models from the provider being left.
|
|
8985
|
+
state.defaultModel = undefined;
|
|
8986
|
+
state.modelCandidates = [];
|
|
8987
|
+
state.modelChecked = new Set();
|
|
8988
|
+
state.modelCursor = 0;
|
|
7611
8989
|
state.modelCapacity = new Map();
|
|
7612
8990
|
this.input = '';
|
|
7613
8991
|
this.cursor = 0;
|
|
@@ -7632,21 +9010,11 @@ export class SshTui {
|
|
|
7632
9010
|
else if (state.step === 'key') {
|
|
7633
9011
|
state.step = 'models';
|
|
7634
9012
|
}
|
|
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
|
-
}
|
|
9013
|
+
else if (isModelsStep(state.step)) {
|
|
9014
|
+
// Leaving the models step is asynchronous: the gateway is asked once more
|
|
9015
|
+
// for whatever the picks are still missing (see finishModelsStep).
|
|
9016
|
+
void this.finishModelsStep(state);
|
|
9017
|
+
return;
|
|
7650
9018
|
}
|
|
7651
9019
|
else if (state.step === 'context') {
|
|
7652
9020
|
state.step = 'confirm';
|
|
@@ -7655,6 +9023,92 @@ export class SshTui {
|
|
|
7655
9023
|
this.cursor = 0;
|
|
7656
9024
|
this.markDirty();
|
|
7657
9025
|
}
|
|
9026
|
+
/**
|
|
9027
|
+
* Open the models picker: every model the listing offered — or the template's
|
|
9028
|
+
* pinned ones when nothing was listed — with the template's models checked.
|
|
9029
|
+
*
|
|
9030
|
+
* Enter used to accept the whole listing, which quietly wrote every model of
|
|
9031
|
+
* the gateway into settings and made the session's model whichever id came
|
|
9032
|
+
* first. The picker makes both the set and the session's model explicit; a
|
|
9033
|
+
* typed list still skips it.
|
|
9034
|
+
*/
|
|
9035
|
+
openOnboardingModelPicker(state) {
|
|
9036
|
+
const template = onboardTemplate(state);
|
|
9037
|
+
const candidates = [...new Set((state.models.length > 0 ? state.models : template.defaultModels).filter(id => id !== ''))];
|
|
9038
|
+
if (candidates.length === 0) {
|
|
9039
|
+
this.pushRow({ kind: 'error', text: t('onboard.needModel') });
|
|
9040
|
+
this.markDirty();
|
|
9041
|
+
return;
|
|
9042
|
+
}
|
|
9043
|
+
const pinned = new Set(template.defaultModels);
|
|
9044
|
+
const checked = new Set();
|
|
9045
|
+
candidates.forEach((id, index) => {
|
|
9046
|
+
if (pinned.has(id))
|
|
9047
|
+
checked.add(index);
|
|
9048
|
+
});
|
|
9049
|
+
if (checked.size === 0)
|
|
9050
|
+
checked.add(0);
|
|
9051
|
+
state.modelCandidates = candidates;
|
|
9052
|
+
state.modelChecked = checked;
|
|
9053
|
+
state.modelCursor = Math.min(...checked);
|
|
9054
|
+
state.step = 'models-pick';
|
|
9055
|
+
this.input = '';
|
|
9056
|
+
this.cursor = 0;
|
|
9057
|
+
this.markDirty();
|
|
9058
|
+
}
|
|
9059
|
+
/** The checked models of the wizard's picker, in listing order. */
|
|
9060
|
+
checkedOnboardingModels(state) {
|
|
9061
|
+
const candidates = state.modelCandidates ?? [];
|
|
9062
|
+
return [...(state.modelChecked ?? new Set())]
|
|
9063
|
+
.sort((left, right) => left - right)
|
|
9064
|
+
.map(index => candidates[index])
|
|
9065
|
+
.filter((id) => id !== undefined);
|
|
9066
|
+
}
|
|
9067
|
+
/** Move the highlight in either picker step. */
|
|
9068
|
+
moveOnboardingModelCursor(delta) {
|
|
9069
|
+
const state = this.onboarding;
|
|
9070
|
+
if (state === undefined)
|
|
9071
|
+
return false;
|
|
9072
|
+
if (state.step === 'models-pick') {
|
|
9073
|
+
const total = state.modelCandidates?.length ?? 0;
|
|
9074
|
+
if (total === 0)
|
|
9075
|
+
return false;
|
|
9076
|
+
state.modelCursor = Math.max(0, Math.min(total - 1, (state.modelCursor ?? 0) + delta));
|
|
9077
|
+
this.markDirty();
|
|
9078
|
+
return true;
|
|
9079
|
+
}
|
|
9080
|
+
if (state.step === 'model-default') {
|
|
9081
|
+
const picked = this.checkedOnboardingModels(state);
|
|
9082
|
+
if (picked.length === 0)
|
|
9083
|
+
return false;
|
|
9084
|
+
state.modelCursor = Math.max(0, Math.min(picked.length - 1, (state.modelCursor ?? 0) + delta));
|
|
9085
|
+
this.markDirty();
|
|
9086
|
+
return true;
|
|
9087
|
+
}
|
|
9088
|
+
return false;
|
|
9089
|
+
}
|
|
9090
|
+
/**
|
|
9091
|
+
* Esc in a picker step goes back one step instead of throwing the wizard
|
|
9092
|
+
* away — the listing is the expensive part and the user is still choosing.
|
|
9093
|
+
*/
|
|
9094
|
+
stepBackOnboarding() {
|
|
9095
|
+
const state = this.onboarding;
|
|
9096
|
+
if (state === undefined)
|
|
9097
|
+
return false;
|
|
9098
|
+
if (state.step === 'models-pick') {
|
|
9099
|
+
state.step = 'models';
|
|
9100
|
+
this.markDirty();
|
|
9101
|
+
return true;
|
|
9102
|
+
}
|
|
9103
|
+
if (state.step === 'model-default') {
|
|
9104
|
+
state.step = 'models-pick';
|
|
9105
|
+
const checked = [...(state.modelChecked ?? new Set())];
|
|
9106
|
+
state.modelCursor = checked.length === 0 ? 0 : Math.min(...checked);
|
|
9107
|
+
this.markDirty();
|
|
9108
|
+
return true;
|
|
9109
|
+
}
|
|
9110
|
+
return false;
|
|
9111
|
+
}
|
|
7658
9112
|
/** Fetch the endpoint's model list into the onboarding wizard's models step. */
|
|
7659
9113
|
async fetchOnboardingModels() {
|
|
7660
9114
|
const state = this.onboarding;
|
|
@@ -7699,6 +9153,11 @@ export class SshTui {
|
|
|
7699
9153
|
this.pushRow({ kind: 'error', text: t('onboard.noModels') });
|
|
7700
9154
|
}
|
|
7701
9155
|
else {
|
|
9156
|
+
// Read the gateway's own route list while the key is still in hand: the
|
|
9157
|
+
// confirm step has no way to ask for it again.
|
|
9158
|
+
if (template.protocols !== undefined) {
|
|
9159
|
+
state.modelEndpoints = await fetchModelEndpoints(baseURL, key);
|
|
9160
|
+
}
|
|
7702
9161
|
state.models = ids;
|
|
7703
9162
|
state.modelCapacity = new Map(discovered.flatMap(model => {
|
|
7704
9163
|
const capacity = {
|
|
@@ -7724,6 +9183,10 @@ export class SshTui {
|
|
|
7724
9183
|
const state = this.onboarding;
|
|
7725
9184
|
if (state === undefined)
|
|
7726
9185
|
return;
|
|
9186
|
+
// `/setup` can switch the parent route (that is the point of adding a key),
|
|
9187
|
+
// so remember where it pointed before the wizard's answer is applied: the
|
|
9188
|
+
// subagent pin question below needs the old value.
|
|
9189
|
+
const previousParentProvider = this.currentProviderId();
|
|
7727
9190
|
let saved = true;
|
|
7728
9191
|
try {
|
|
7729
9192
|
const credentials = this.ctx.get('credentials');
|
|
@@ -7732,14 +9195,14 @@ export class SshTui {
|
|
|
7732
9195
|
if (state.providerType === 'official') {
|
|
7733
9196
|
const envRef = 'DEEPSEEK_API_KEY';
|
|
7734
9197
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7735
|
-
const model = state.models[0] ?? 'deepseek-v4-pro';
|
|
9198
|
+
const model = state.defaultModel ?? state.models[0] ?? 'deepseek-v4-pro';
|
|
7736
9199
|
await this.ctx.get('agentDefaultModel')?.saveSelection({ provider: 'deepseek-official', model });
|
|
7737
9200
|
if (this.selectionRef !== undefined) {
|
|
7738
9201
|
this.selectionRef.current = { provider: 'deepseek-official', model };
|
|
7739
9202
|
}
|
|
7740
9203
|
this.onSelectionChanged?.({ provider: 'deepseek-official', model });
|
|
7741
9204
|
await this.rememberRoute({ provider: 'deepseek-official', model });
|
|
7742
|
-
await this.syncSubagentToProvider('deepseek-official', state.models);
|
|
9205
|
+
await this.syncSubagentToProvider('deepseek-official', state.models, previousParentProvider !== 'deepseek-official');
|
|
7743
9206
|
if (state.baseUrl !== '' && settings !== undefined) {
|
|
7744
9207
|
await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
|
|
7745
9208
|
this.pushRow({ kind: 'system', text: t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }) });
|
|
@@ -7753,7 +9216,7 @@ export class SshTui {
|
|
|
7753
9216
|
}
|
|
7754
9217
|
else {
|
|
7755
9218
|
const envRef = envRefForId(state.providerId);
|
|
7756
|
-
const model = state.models[0];
|
|
9219
|
+
const model = state.defaultModel ?? state.models[0];
|
|
7757
9220
|
// OpenCode / third-party (llm-pi-ai) routes have no adapter-level
|
|
7758
9221
|
// reasoning default. Re-running setup must not silently drop the
|
|
7759
9222
|
// effort that makes thinking arrive as `reasoning` blocks; default it
|
|
@@ -7769,7 +9232,44 @@ export class SshTui {
|
|
|
7769
9232
|
: await defaultReasoningEffort(llm, state.providerId, model);
|
|
7770
9233
|
const reasoningEfforts = onboardingReasoningEfforts(state.providerType, defaultEffort === undefined ? undefined : String(defaultEffort));
|
|
7771
9234
|
const existing = this.piAiProviderProfile(state.providerId);
|
|
7772
|
-
|
|
9235
|
+
// One gateway can already be spread over sibling entries (an earlier
|
|
9236
|
+
// setup, or a hand-written profile): the pool is every model the gateway
|
|
9237
|
+
// has, so re-running setup re-homes them instead of duplicating or
|
|
9238
|
+
// dropping any.
|
|
9239
|
+
const baseProtocol = declaredProtocol(existing?.api) ?? template.api ?? 'openai-completions';
|
|
9240
|
+
const siblingEntries = template.protocols === undefined
|
|
9241
|
+
? []
|
|
9242
|
+
: template.protocols
|
|
9243
|
+
.filter(protocol => protocol !== baseProtocol)
|
|
9244
|
+
.map(protocol => ({
|
|
9245
|
+
protocol,
|
|
9246
|
+
profile: this.piAiProviderProfile(siblingProviderId(state.providerId, protocol)),
|
|
9247
|
+
}));
|
|
9248
|
+
const existingModels = [
|
|
9249
|
+
...(Array.isArray(existing?.models) ? existing.models : []),
|
|
9250
|
+
...siblingEntries.flatMap(entry => (Array.isArray(entry.profile?.models) ? entry.profile.models : [])),
|
|
9251
|
+
];
|
|
9252
|
+
// Where each model already sits, so the split re-homes only what the
|
|
9253
|
+
// gateway itself says moved: a working placement is never relocated by
|
|
9254
|
+
// the table, and `/setup` keeps the promise that an existing model list
|
|
9255
|
+
// only grows.
|
|
9256
|
+
const modelPlacement = new Map();
|
|
9257
|
+
const recordPlacement = (models, protocol) => {
|
|
9258
|
+
if (!Array.isArray(models))
|
|
9259
|
+
return;
|
|
9260
|
+
for (const raw of models) {
|
|
9261
|
+
const id = typeof raw === 'string'
|
|
9262
|
+
? raw
|
|
9263
|
+
: typeof raw === 'object' && raw !== null && typeof raw.id === 'string'
|
|
9264
|
+
? raw.id
|
|
9265
|
+
: '';
|
|
9266
|
+
if (id !== '' && !modelPlacement.has(id))
|
|
9267
|
+
modelPlacement.set(id, protocol);
|
|
9268
|
+
}
|
|
9269
|
+
};
|
|
9270
|
+
recordPlacement(existing?.models, baseProtocol);
|
|
9271
|
+
for (const entry of siblingEntries)
|
|
9272
|
+
recordPlacement(entry.profile?.models, entry.protocol);
|
|
7773
9273
|
// The confirm step can be reached without walking the models step (and
|
|
7774
9274
|
// a saved catalog answer may have landed after it), so size the picks
|
|
7775
9275
|
// here too: the call is idempotent and fills only missing windows.
|
|
@@ -7798,36 +9298,74 @@ export class SshTui {
|
|
|
7798
9298
|
// and an absent key defers to the provider's own environment auth.
|
|
7799
9299
|
const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
|
|
7800
9300
|
const keyless = catalogRoute && state.key === '';
|
|
7801
|
-
const
|
|
9301
|
+
const baseURL = state.baseUrl === ''
|
|
9302
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
9303
|
+
: state.baseUrl;
|
|
9304
|
+
// A gateway with `protocols` keeps one wizard row and lands in several
|
|
9305
|
+
// provider entries, one per protocol its models actually speak. The base
|
|
9306
|
+
// id keeps the protocol it already declares (so routes naming it never
|
|
9307
|
+
// change meaning); a fresh gateway takes the template's pinned one.
|
|
9308
|
+
const split = template.protocols === undefined
|
|
9309
|
+
? new Map([[baseProtocol, mergedIds]])
|
|
9310
|
+
: splitModelsByProtocol({
|
|
9311
|
+
models: mergedIds,
|
|
9312
|
+
...(state.modelEndpoints === undefined ? {} : { advertised: state.modelEndpoints }),
|
|
9313
|
+
...(modelPlacement.size === 0 ? {} : { existing: modelPlacement }),
|
|
9314
|
+
...(gatewayModelTable(baseURL) === undefined ? {} : { table: gatewayModelTable(baseURL) }),
|
|
9315
|
+
fallback: baseProtocol,
|
|
9316
|
+
preference: template.protocols,
|
|
9317
|
+
});
|
|
9318
|
+
const profileFor = (protocol, models) => ({
|
|
7802
9319
|
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
7803
9320
|
? existing.displayName
|
|
7804
9321
|
: template.label,
|
|
7805
9322
|
...(keyless ? {} : { apiKeyEnv: envRef }),
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
: state.baseUrl,
|
|
7813
|
-
}),
|
|
7814
|
-
models: mergedIds.map(id => ({
|
|
9323
|
+
// A catalog route leaves the protocol to the installed catalog: it
|
|
9324
|
+
// used to write no `api` at all, and pinning one here would override
|
|
9325
|
+
// whatever that catalog serves.
|
|
9326
|
+
api: template.protocols === undefined ? (template.api ?? existing?.api) : protocol,
|
|
9327
|
+
...(catalogRoute && state.baseUrl === '' ? {} : { baseURL }),
|
|
9328
|
+
models: models.map(id => ({
|
|
7815
9329
|
id,
|
|
7816
9330
|
...(state.modelCapacity?.get(id) ?? template.defaultModelCapacity?.[id] ?? {}),
|
|
7817
9331
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
7818
9332
|
})),
|
|
7819
9333
|
...(state.routeContextWindow === undefined ? {} : { defaultContextWindow: state.routeContextWindow }),
|
|
7820
9334
|
...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
|
|
7821
|
-
};
|
|
9335
|
+
});
|
|
9336
|
+
// Write every protocol that ends up holding models, plus any sibling
|
|
9337
|
+
// that exists but is now empty because its models moved elsewhere.
|
|
9338
|
+
const writes = [...split]
|
|
9339
|
+
.filter(([, models]) => models.length > 0)
|
|
9340
|
+
.map(([protocol, models]) => ({ protocol, models }));
|
|
9341
|
+
for (const sibling of siblingEntries) {
|
|
9342
|
+
// Only an entry that exists can be emptied: never create a sibling
|
|
9343
|
+
// just to say it holds nothing.
|
|
9344
|
+
if (sibling.profile === undefined)
|
|
9345
|
+
continue;
|
|
9346
|
+
if (!writes.some(entry => entry.protocol === sibling.protocol)) {
|
|
9347
|
+
writes.push({ protocol: sibling.protocol, models: [] });
|
|
9348
|
+
}
|
|
9349
|
+
}
|
|
9350
|
+
const entryId = (protocol) => protocol === baseProtocol ? state.providerId : siblingProviderId(state.providerId, protocol);
|
|
9351
|
+
// The route the wizard hands over must be the entry that actually lists
|
|
9352
|
+
// the chosen model — with a split they are no longer the same id.
|
|
9353
|
+
const ownerId = entryId(writes.find(entry => entry.models.includes(model))?.protocol ?? baseProtocol);
|
|
7822
9354
|
if (settings === undefined) {
|
|
7823
9355
|
this.pushRow({ kind: 'error', text: t('onboard.settingsMissing') });
|
|
7824
9356
|
saved = false;
|
|
7825
9357
|
}
|
|
7826
9358
|
else {
|
|
7827
|
-
await settings.mutate(settingsNamespace('llm-pi-ai'),
|
|
7828
|
-
|
|
7829
|
-
|
|
9359
|
+
await settings.mutate(settingsNamespace('llm-pi-ai'), writes.map(entry => ({
|
|
9360
|
+
op: 'set',
|
|
9361
|
+
path: ['providers', entryId(entry.protocol)],
|
|
9362
|
+
value: profileFor(entry.protocol, entry.models),
|
|
9363
|
+
})));
|
|
7830
9364
|
this.pushRow({ kind: 'system', text: t('onboard.providerSaved', { id: state.providerId, path: displayDshPath('settings.yaml') }) });
|
|
9365
|
+
if (template.protocols !== undefined) {
|
|
9366
|
+
const summary = writes.map(entry => `${GATEWAY_PROTOCOL_SUFFIX[entry.protocol]} ${entry.models.length}`).join(' · ');
|
|
9367
|
+
this.pushRow({ kind: 'system', text: t('onboard.providerSplit', { id: state.providerId, summary }) });
|
|
9368
|
+
}
|
|
7831
9369
|
}
|
|
7832
9370
|
// Only store the key when its provider profile actually made it to
|
|
7833
9371
|
// settings; otherwise the saved key points at an unusable route.
|
|
@@ -7835,7 +9373,7 @@ export class SshTui {
|
|
|
7835
9373
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7836
9374
|
if (saved) {
|
|
7837
9375
|
const selection = {
|
|
7838
|
-
provider:
|
|
9376
|
+
provider: ownerId,
|
|
7839
9377
|
model,
|
|
7840
9378
|
...(defaultEffort === undefined ? {} : { reasoningEffort: defaultEffort }),
|
|
7841
9379
|
};
|
|
@@ -7845,10 +9383,10 @@ export class SshTui {
|
|
|
7845
9383
|
}
|
|
7846
9384
|
this.onSelectionChanged?.(selection);
|
|
7847
9385
|
await this.rememberRoute(selection);
|
|
7848
|
-
await this.syncSubagentToProvider(
|
|
9386
|
+
await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
|
|
7849
9387
|
this.pushRow({
|
|
7850
9388
|
kind: 'system',
|
|
7851
|
-
text: t('onboard.customDone', { id:
|
|
9389
|
+
text: t('onboard.customDone', { id: ownerId, model }),
|
|
7852
9390
|
});
|
|
7853
9391
|
}
|
|
7854
9392
|
}
|
|
@@ -7872,6 +9410,10 @@ export class SshTui {
|
|
|
7872
9410
|
const shadowed = shadowing !== undefined && shadowing !== '';
|
|
7873
9411
|
if (credentials !== undefined && !shadowed) {
|
|
7874
9412
|
await credentials.set(credentialRef(envRef), key);
|
|
9413
|
+
// The store writes it; we are the reason it exists. On Windows that write
|
|
9414
|
+
// ignored any mode the store passed, so the file that holds every API key
|
|
9415
|
+
// gets its ACL tightened here.
|
|
9416
|
+
await restrictPathToUser(join(dshHomeDir(), '.credentials.yaml'), { mode: 0o600 });
|
|
7875
9417
|
this.pushRow({ kind: 'system', text: t('onboard.credSaved', { env: envRef, path: displayDshPath('.credentials.yaml') }) });
|
|
7876
9418
|
return;
|
|
7877
9419
|
}
|
|
@@ -7882,13 +9424,13 @@ export class SshTui {
|
|
|
7882
9424
|
? IS_WINDOWS
|
|
7883
9425
|
? t('onboard.envShadowWin', { env: envRef })
|
|
7884
9426
|
: t('onboard.envShadowUnix', { env: envRef })
|
|
7885
|
-
: t('onboard.credMissing', { path: displayDshPath(
|
|
9427
|
+
: t('onboard.credMissing', { path: displayDshPath(envFileName()) }),
|
|
7886
9428
|
});
|
|
7887
9429
|
}
|
|
7888
9430
|
/** Write launch-environment overrides so they beat system-injected variables. */
|
|
7889
9431
|
async writeLaunchEnv(entries) {
|
|
7890
9432
|
const home = dshHomeDir();
|
|
7891
|
-
const file = join(home,
|
|
9433
|
+
const file = join(home, envFileName());
|
|
7892
9434
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
7893
9435
|
if (IS_WINDOWS) {
|
|
7894
9436
|
let previous = '';
|
|
@@ -7912,6 +9454,10 @@ export class SshTui {
|
|
|
7912
9454
|
const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
|
|
7913
9455
|
const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
|
|
7914
9456
|
await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
|
|
9457
|
+
// This file holds API keys. `mode` covers POSIX; on Windows the same
|
|
9458
|
+
// intent has to be an ACL or the keys sit under whatever the parent
|
|
9459
|
+
// directory allowed.
|
|
9460
|
+
await restrictPathToUser(file, { mode: 0o600 });
|
|
7915
9461
|
// Persist for future processes; best-effort, env.cmd remains as a manual fallback.
|
|
7916
9462
|
await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
|
|
7917
9463
|
return;
|
|
@@ -7935,6 +9481,7 @@ export class SshTui {
|
|
|
7935
9481
|
});
|
|
7936
9482
|
const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
|
|
7937
9483
|
await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
|
|
9484
|
+
await restrictPathToUser(file, { mode: 0o600 });
|
|
7938
9485
|
await this.ensurePosixEnvHook();
|
|
7939
9486
|
}
|
|
7940
9487
|
/** Persist one variable into the Windows user environment (best-effort). */
|
|
@@ -7998,8 +9545,12 @@ export class SshTui {
|
|
|
7998
9545
|
}
|
|
7999
9546
|
if (this.dialog.kind === 'confirm')
|
|
8000
9547
|
this.closeConfirm('cancel');
|
|
8001
|
-
else if (this.dialog.kind === 'onboarding')
|
|
8002
|
-
|
|
9548
|
+
else if (this.dialog.kind === 'onboarding') {
|
|
9549
|
+
// Inside the models picker Esc steps back through the wizard's own
|
|
9550
|
+
// steps; anywhere else it still abandons the wizard.
|
|
9551
|
+
if (!this.stepBackOnboarding())
|
|
9552
|
+
this.cancelOnboarding();
|
|
9553
|
+
}
|
|
8003
9554
|
else
|
|
8004
9555
|
this.dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
|
|
8005
9556
|
return;
|
|
@@ -8193,6 +9744,16 @@ export class SshTui {
|
|
|
8193
9744
|
this.leaveHistoryBrowse();
|
|
8194
9745
|
this.write(osc52Clipboard(text));
|
|
8195
9746
|
this.pushRow({ kind: 'system', text: notice });
|
|
9747
|
+
// The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
|
|
9748
|
+
// never implemented OSC 52, conhost and screen cannot, Konsole only from
|
|
9749
|
+
// 24.12, and xterm/tmux need configuration. The write still goes out — it is
|
|
9750
|
+
// how the terminals that *do* honour it work — but where the table does not
|
|
9751
|
+
// promise it the user is told once per session rather than at every copy, or
|
|
9752
|
+
// not at all until they paste into an empty clipboard.
|
|
9753
|
+
if (!this.terminalCaps.osc52 && !this.osc52HintShown) {
|
|
9754
|
+
this.osc52HintShown = true;
|
|
9755
|
+
this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
|
|
9756
|
+
}
|
|
8196
9757
|
this.markDirty();
|
|
8197
9758
|
}
|
|
8198
9759
|
copyFocusedCard(arg = '') {
|
|
@@ -8285,6 +9846,10 @@ export class SshTui {
|
|
|
8285
9846
|
const text = this.input.trim();
|
|
8286
9847
|
if (text === '')
|
|
8287
9848
|
return;
|
|
9849
|
+
// Framed mode paints the composer; line mode has to put the typed line
|
|
9850
|
+
// into the log itself, or a `tee` never sees what the user sent.
|
|
9851
|
+
if (this.lineMode)
|
|
9852
|
+
this.pushRow({ kind: 'user', text: t('line.prompt', { text }) });
|
|
8288
9853
|
if (text.startsWith('/')) {
|
|
8289
9854
|
this.historyIndex = this.history.length;
|
|
8290
9855
|
this.historyDraft = '';
|
|
@@ -8499,7 +10064,7 @@ export class SshTui {
|
|
|
8499
10064
|
...(quota === undefined ? {} : { quota }),
|
|
8500
10065
|
...(this.contextPressure === undefined ? {} : { context: this.contextPressure }),
|
|
8501
10066
|
parentModel: model,
|
|
8502
|
-
...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
|
|
10067
|
+
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
8503
10068
|
subModel: sub.model,
|
|
8504
10069
|
cwd: this.workspaceCwd(),
|
|
8505
10070
|
});
|
|
@@ -8563,16 +10128,20 @@ export class SshTui {
|
|
|
8563
10128
|
this.pushRow({ kind: 'system', text: t('sub.none') });
|
|
8564
10129
|
}
|
|
8565
10130
|
else {
|
|
8566
|
-
const lines = [...this.activeSubagents.entries()].map(([
|
|
10131
|
+
const lines = [...this.activeSubagents.entries()].map(([, sub]) => {
|
|
8567
10132
|
const card = this.findSubagentRow(sub.id);
|
|
8568
|
-
const label = card
|
|
8569
|
-
const activity = card
|
|
10133
|
+
const label = card === undefined ? this.subagentNameFor(sub.id) : subagentDisplayName(card);
|
|
10134
|
+
const activity = card === undefined ? '' : (() => {
|
|
10135
|
+
const summary = subagentChipSummary(card);
|
|
10136
|
+
return summary === '' ? '' : ` · ${summary}`;
|
|
10137
|
+
})();
|
|
8570
10138
|
return t('sub.listLine', {
|
|
8571
10139
|
label,
|
|
10140
|
+
// `/subagents kill` takes this id, and the list is where a user
|
|
10141
|
+
// looks it up: the chip itself stays name-only.
|
|
8572
10142
|
id: sub.id,
|
|
8573
10143
|
provider: sub.provider,
|
|
8574
10144
|
seconds: Math.floor((Date.now() - sub.startedAt) / 1000),
|
|
8575
|
-
run: runId.slice(0, 8),
|
|
8576
10145
|
activity,
|
|
8577
10146
|
});
|
|
8578
10147
|
});
|
|
@@ -8874,7 +10443,16 @@ export function mountTui(ctx, config) {
|
|
|
8874
10443
|
else
|
|
8875
10444
|
process.exit(1);
|
|
8876
10445
|
};
|
|
8877
|
-
|
|
10446
|
+
// The explicit `return undefined` is deliberate, not noise: 0.1.6-alpha.2 turns
|
|
10447
|
+
// `agent/created` into a serial event whose handler must return
|
|
10448
|
+
// `undefined | Promise<undefined>`, and a bare `void` body is *not* assignable
|
|
10449
|
+
// to that (verified by compiling this file against the published 0.1.6-alpha.2
|
|
10450
|
+
// types: TS2322/TS2345). Returning `undefined` satisfies the whole 0.1.5 line
|
|
10451
|
+
// too, so the handler is ready for the next line without a compat branch.
|
|
10452
|
+
const disposeCreated = ctx.on('agent/created', ({ agent }) => {
|
|
10453
|
+
start(agent);
|
|
10454
|
+
return undefined;
|
|
10455
|
+
});
|
|
8878
10456
|
const disposeFailure = ctx.on('agent-loop/config-start-failed', ({ sessionId: failedSessionId, error }) => fail(failedSessionId, error));
|
|
8879
10457
|
const stopWaiting = () => {
|
|
8880
10458
|
disposeCreated();
|