dsh-ssh-tui 0.7.1 → 0.7.2

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