dsh-ssh-tui 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.en.md +90 -7
  2. package/README.md +58 -7
  3. package/lib/approval-cache.js +12 -9
  4. package/lib/approval-cache.js.map +1 -1
  5. package/lib/color-depth.js +26 -5
  6. package/lib/color-depth.js.map +1 -1
  7. package/lib/diag.js +60 -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 +63 -41
  17. package/lib/i18n/en.js.map +1 -1
  18. package/lib/i18n/zh.js +63 -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 +1796 -274
  45. package/lib/tui.js.map +1 -1
  46. package/lib/types/color-depth.d.ts +4 -4
  47. package/lib/types/diag.d.ts +11 -0
  48. package/lib/types/dialogs.d.ts +4 -0
  49. package/lib/types/display-sock.d.ts +52 -1
  50. package/lib/types/footer.d.ts +18 -4
  51. package/lib/types/gateway-protocol.d.ts +88 -0
  52. package/lib/types/job-label.d.ts +38 -7
  53. package/lib/types/plan.d.ts +94 -4
  54. package/lib/types/platform.d.ts +50 -0
  55. package/lib/types/route-memory.d.ts +17 -22
  56. package/lib/types/session-list.d.ts +5 -0
  57. package/lib/types/session-lock.d.ts +9 -0
  58. package/lib/types/session-route.d.ts +191 -0
  59. package/lib/types/subagent-model.d.ts +53 -0
  60. package/lib/types/term-text.d.ts +3 -2
  61. package/lib/types/tool-present.d.ts +2 -17
  62. package/lib/types/transcript-types.d.ts +44 -1
  63. package/lib/types/tui.d.ts +292 -2
  64. package/lib/types/update-check.d.ts +42 -1
  65. package/lib/update-check.js +31 -2
  66. package/lib/update-check.js.map +1 -1
  67. 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
  }
@@ -1422,6 +1586,14 @@ export class SshTui {
1422
1586
  probeState: this.paintProbed ? 'measured' : this.paintLink === 'ssh' ? 'unknown' : 'unprobed',
1423
1587
  },
1424
1588
  ...(this.paintIntervalMs === undefined ? {} : { paintIntervalMs: this.paintIntervalMs }),
1589
+ // The depth this process actually paints with, plus the hints behind it:
1590
+ // on Windows TERM is normally unset, which used to read as "no colour".
1591
+ color: {
1592
+ depth: this.colorDepth,
1593
+ ...((process.env.TERM ?? '') === '' ? {} : { term: process.env.TERM }),
1594
+ ...((process.env.COLORTERM ?? '') === '' ? {} : { colorTerm: process.env.COLORTERM }),
1595
+ windowsTerminal: (process.env.WT_SESSION ?? '') !== '',
1596
+ },
1425
1597
  });
1426
1598
  this.pushRow({ kind: 'diag', text: formatDiag(snapshot).join('\n') });
1427
1599
  this.markDirty();
@@ -1453,7 +1625,7 @@ export class SshTui {
1453
1625
  ...(model === undefined ? {} : { model }),
1454
1626
  routes: Object.keys(providers),
1455
1627
  routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
1456
- ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
1628
+ ...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
1457
1629
  ...(sub.model === undefined ? {} : { subModel: sub.model }),
1458
1630
  };
1459
1631
  }
@@ -1594,7 +1766,7 @@ export class SshTui {
1594
1766
  return true;
1595
1767
  if (this.llmRetry !== undefined)
1596
1768
  return true;
1597
- if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
1769
+ if (this.compactionRunning())
1598
1770
  return true;
1599
1771
  return false;
1600
1772
  }
@@ -1920,7 +2092,8 @@ export class SshTui {
1920
2092
  this.paintRttMs = rttMs;
1921
2093
  if (!(Number.isFinite(envOverride) && envOverride > 0)) {
1922
2094
  this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
1923
- this.startRenderTimer();
2095
+ if (!this.lineMode)
2096
+ this.startRenderTimer();
1924
2097
  }
1925
2098
  this.markDirty();
1926
2099
  }
@@ -2034,7 +2207,7 @@ export class SshTui {
2034
2207
  return false;
2035
2208
  if (this.streaming?.text)
2036
2209
  return false;
2037
- if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
2210
+ if (this.compactionRunning())
2038
2211
  return false;
2039
2212
  if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
2040
2213
  return false;
@@ -2053,12 +2226,25 @@ export class SshTui {
2053
2226
  ...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
2054
2227
  ...(liveTool !== undefined || liveSub === undefined
2055
2228
  ? {}
2056
- : { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
2229
+ : { toolTitle: subagentDisplayName(liveSub), toolSummary: subagentChipSummary(liveSub) }),
2057
2230
  ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
2058
2231
  };
2059
2232
  }
2060
2233
  findSubagentRow(sessionId) {
2061
- return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
2234
+ return this.rows.findLast((row) => row.kind === 'subagent' && (row.sessionId === sessionId || row.childSessionId === sessionId));
2235
+ }
2236
+ subagentInspectId(row) {
2237
+ return row.childSessionId ?? row.sessionId;
2238
+ }
2239
+ /** Courtesy name for a child session; never the raw session hash. */
2240
+ subagentNameFor(sessionId) {
2241
+ const id = sessionId === undefined ? '' : String(sessionId);
2242
+ if (id === '' || id === String(this.agent.id))
2243
+ return t('approval.thisSession');
2244
+ const row = this.findSubagentRow(id);
2245
+ if (row !== undefined)
2246
+ return subagentDisplayName(row);
2247
+ return subagentCourtesyName({ sessionId: id, fallback: t('card.subagent') });
2062
2248
  }
2063
2249
  findLivePlanRow() {
2064
2250
  return findLivePlanRow(this.rows);
@@ -2246,7 +2432,7 @@ export class SshTui {
2246
2432
  return;
2247
2433
  const body = buffer
2248
2434
  .map(piece => (piece.emphasis
2249
- ? `\x1b[7m${this.styleLine(line.kind, piece.text)}\x1b[27m`
2435
+ ? this.styleEmphasisedPiece(line.kind, piece.text)
2250
2436
  : this.styleLine(line.kind, piece.text)))
2251
2437
  .join('');
2252
2438
  rows.push(` ${body}`);
@@ -2268,9 +2454,59 @@ export class SshTui {
2268
2454
  flush();
2269
2455
  return rows.length === 0 ? [this.styleLine(line.kind, '')] : rows;
2270
2456
  }
2457
+ /**
2458
+ * A body row whose parts carry different roles: the two columns of a
2459
+ * side-by-side diff.
2460
+ *
2461
+ * No single kind fits such a row — the left column is a removal, the right an
2462
+ * addition — so each part is painted with its own style, the gutter keeps the
2463
+ * row's own (neutral) one, and a word emphasis rides inside whichever column
2464
+ * it belongs to. The first column takes the leading indent and the last one
2465
+ * runs to the end of the row, so both bars are flush.
2466
+ */
2467
+ styleColumnedLine(line, width) {
2468
+ const indent = 2;
2469
+ const padded = padToWidth(` ${line.text}`, Math.max(1, width));
2470
+ const cut = (at) => Math.max(0, Math.min(padded.length, at + indent));
2471
+ const columns = (line.columns ?? []).map((column, index, all) => ({
2472
+ start: index === 0 ? 0 : cut(column.start),
2473
+ end: index === all.length - 1 ? padded.length : cut(column.end),
2474
+ kind: column.kind,
2475
+ }));
2476
+ const cuts = new Set([0, padded.length]);
2477
+ for (const span of line.spans ?? []) {
2478
+ cuts.add(cut(span.start));
2479
+ cuts.add(cut(span.end));
2480
+ }
2481
+ for (const column of line.columns ?? []) {
2482
+ cuts.add(cut(column.start));
2483
+ cuts.add(cut(column.end));
2484
+ }
2485
+ const ordered = [...cuts].sort((left, right) => left - right);
2486
+ const pieces = [];
2487
+ for (let at = 0; at + 1 < ordered.length; at += 1) {
2488
+ const from = ordered[at] ?? 0;
2489
+ const to = ordered[at + 1] ?? from;
2490
+ if (to <= from)
2491
+ continue;
2492
+ const column = columns.find(item => item.start <= from && item.end >= to);
2493
+ const emphasis = (line.spans ?? []).some(span => cut(span.start) <= from && cut(span.end) >= to);
2494
+ const text = padded.slice(from, to);
2495
+ const kind = column?.kind ?? line.kind;
2496
+ pieces.push(emphasis ? this.styleEmphasisedPiece(kind, text) : this.styleLine(kind, text));
2497
+ }
2498
+ return pieces.length === 0 ? [this.styleLine(line.kind, '')] : [pieces.join('')];
2499
+ }
2271
2500
  paintToolBodyLine(addDisplay, row, line, width) {
2272
2501
  const inner = Math.max(1, width - 2);
2273
2502
  const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
2503
+ // A line that holds two roles (the columns of a side-by-side diff) is
2504
+ // painted part by part; its emphasis rides inside whichever part it is in.
2505
+ if (line.columns !== undefined && line.columns.length > 0) {
2506
+ for (const painted of this.styleColumnedLine(line, width))
2507
+ addDisplay(painted, row);
2508
+ return;
2509
+ }
2274
2510
  // An emphasised line is styled in parts: the escapes wrap *styled* pieces,
2275
2511
  // because the painter's sanitiser strips any escape that reaches it inside
2276
2512
  // the text — which is how a literal `[7m` once appeared on screen.
@@ -2304,7 +2540,7 @@ export class SshTui {
2304
2540
  const dialog = this.dialog;
2305
2541
  if (dialog === undefined || dialog.kind !== 'inspect')
2306
2542
  return;
2307
- const header = this.styleLine('system', truncateToWidth(t('tool.inspectTitle', { title: dialog.title }), width));
2543
+ const header = this.styleLine('system', truncateToWidth(dialog.title, width));
2308
2544
  const hint = this.styleLine('system', truncateToWidth(t('tool.inspectHint'), width));
2309
2545
  const divider = this.styleLine('system', repeatToWidth('─', width));
2310
2546
  const bodyBudget = Math.max(1, height - 4);
@@ -2315,18 +2551,19 @@ export class SshTui {
2315
2551
  for (const wrapped of wrap(line.text, inner)) {
2316
2552
  const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
2317
2553
  const kind = line.kind;
2318
- rendered.push(kind === 'diff-add' || kind === 'diff-del' || kind === 'diff-path'
2319
- ? this.styleLine(kind, body)
2320
- : kind === 'todo-done' || kind === 'todo-active' || kind === 'todo-pending'
2321
- || kind === 'todo-failed' || kind === 'todo-skipped'
2322
- ? this.styleLine(kind, body)
2323
- : kind === 'error'
2324
- ? this.styleLine('error', body)
2325
- : kind === 'assistant'
2326
- ? this.styleLine('assistant', body)
2327
- : this.styleLine('tool-result', body));
2554
+ const styled = this.styleLine(kind === 'subagent' ? 'subagent-header' : kind, body);
2555
+ rendered.push(this.markSearchRow(styled, wrapped));
2328
2556
  }
2329
2557
  }
2558
+ // `/find` reported a hit inside this body, so put it on screen: the overlay
2559
+ // used to open at the top and leave the reader scrolling for it. Done once,
2560
+ // so PgDn afterwards keeps the reader's own position.
2561
+ if (dialog.searchRevealed !== true && this.searchNeedle !== '') {
2562
+ const at = rendered.findIndex(row => searchContains(stripAnsi(row), this.searchNeedle));
2563
+ if (at >= 0)
2564
+ dialog.offset = Math.max(0, at - 1);
2565
+ dialog.searchRevealed = true;
2566
+ }
2330
2567
  const maxOffset = Math.max(0, rendered.length - bodyBudget);
2331
2568
  if (dialog.offset > maxOffset)
2332
2569
  dialog.offset = maxOffset;
@@ -2361,15 +2598,67 @@ export class SshTui {
2361
2598
  this.lastChromeStart = 0;
2362
2599
  this.lastTranscriptStart = -1;
2363
2600
  }
2601
+ /**
2602
+ * Reverse-video (or a `»` in monochrome) on the overlay row that holds the
2603
+ * current `/find` needle, matching what the transcript does for the same hit.
2604
+ */
2605
+ markSearchRow(styled, plain) {
2606
+ const needle = this.searchNeedle;
2607
+ if (needle === '' || !searchContains(plain, needle))
2608
+ return styled;
2609
+ return this.color ? highlightAnsiNeedle(styled, needle) : `» ${styled}`;
2610
+ }
2364
2611
  openToolInspect(row) {
2365
2612
  const lines = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
2613
+ const title = t('tool.inspectTitle', { title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}` });
2614
+ if (this.echoInspectToLog(title, lines))
2615
+ return;
2616
+ this.openDialog({
2617
+ kind: 'inspect',
2618
+ title,
2619
+ lines,
2620
+ offset: 0,
2621
+ });
2622
+ }
2623
+ openSubagentInspect(row) {
2624
+ const dialog = this.dialog;
2625
+ if (dialog?.kind === 'inspect' && dialog.subagentSessionId === this.subagentInspectId(row)) {
2626
+ dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
2627
+ dialog.lines = subagentInspectLines(row);
2628
+ this.markDirty();
2629
+ return;
2630
+ }
2631
+ const title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
2632
+ const lines = subagentInspectLines(row);
2633
+ if (this.echoInspectToLog(title, lines))
2634
+ return;
2366
2635
  this.openDialog({
2367
2636
  kind: 'inspect',
2368
- title: `${row.title}${row.summary === '' ? '' : ` ${row.summary}`}`,
2637
+ title,
2369
2638
  lines,
2370
2639
  offset: 0,
2640
+ subagentSessionId: this.subagentInspectId(row),
2371
2641
  });
2372
2642
  }
2643
+ /**
2644
+ * Line mode has no framed scroller, so an inspect body goes into the log.
2645
+ *
2646
+ * Opening the modal there printed nothing and then swallowed the next Enter:
2647
+ * an invisible dialog. The log is the only detail surface line mode has, and
2648
+ * writing it keeps the input free.
2649
+ */
2650
+ echoInspectToLog(title, lines) {
2651
+ if (!this.lineMode)
2652
+ return false;
2653
+ const shown = lines.slice(0, LINE_MODE_INSPECT_LINES);
2654
+ const body = [title, ...shown.map(line => line.text)];
2655
+ if (lines.length > shown.length) {
2656
+ body.push(t('tool.bodyMoreLines', { count: lines.length - shown.length }));
2657
+ }
2658
+ this.pushRow({ kind: 'system', text: body.join('\n') });
2659
+ this.markDirty();
2660
+ return true;
2661
+ }
2373
2662
  closeInspect() {
2374
2663
  if (this.dialog?.kind !== 'inspect')
2375
2664
  return;
@@ -2387,12 +2676,12 @@ export class SshTui {
2387
2676
  if (!row.expanded) {
2388
2677
  const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
2389
2678
  const styled = paint(collapsed);
2390
- addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2679
+ addDisplay(this.selectLine(styled, focused), row);
2391
2680
  return;
2392
2681
  }
2393
2682
  for (const wrapped of wrap(plain, width)) {
2394
2683
  const styled = paint(wrapped);
2395
- addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
2684
+ addDisplay(this.selectLine(styled, focused), row);
2396
2685
  }
2397
2686
  }
2398
2687
  /** Move the expand/collapse focus among reasoning and tool rows. */
@@ -2426,13 +2715,31 @@ export class SshTui {
2426
2715
  return;
2427
2716
  this.toggleCard(target);
2428
2717
  }
2718
+ /**
2719
+ * Whether an inline tool body would be cut short.
2720
+ *
2721
+ * Two things stop a body short: it cannot fit the workspace, or the measured
2722
+ * link is slow enough that the paint budget trims it. Both leave the reader
2723
+ * looking at part of a diff under a header that counts all of it, and both
2724
+ * are what the overlay is for — so both answers are the overlay, and the
2725
+ * card's own `Enter 全览` hint stays true.
2726
+ */
2727
+ toolBodyTruncated(target, width, height) {
2728
+ const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
2729
+ if (body.length > toolBodyLineLimit(linkQualityOf(this.paintLink, this.paintRttMs)))
2730
+ return true;
2731
+ return !toolBodyFitsWorkspace(wrappedToolBodyLineCount(body, width), this.workspaceRowsFor(width, height));
2732
+ }
2429
2733
  toggleCard(target) {
2430
- if (target.kind === 'tool' && !target.expanded) {
2734
+ if (target.kind === 'subagent') {
2735
+ this.focusedRow = target;
2736
+ this.openSubagentInspect(target);
2737
+ return;
2738
+ }
2739
+ if (target.kind === 'tool') {
2431
2740
  const width = Math.max(10, this.screenColumns());
2432
2741
  const height = Math.max(6, this.screenRows());
2433
- const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
2434
- const bodyRows = wrappedToolBodyLineCount(body, width);
2435
- if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
2742
+ if (this.toolBodyTruncated(target, width, height)) {
2436
2743
  this.focusedRow = target;
2437
2744
  this.openToolInspect(target);
2438
2745
  return;
@@ -2459,6 +2766,8 @@ export class SshTui {
2459
2766
  const height = Math.max(6, this.screenRows());
2460
2767
  const workspace = this.workspaceRowsFor(width, height);
2461
2768
  for (const row of rows) {
2769
+ if (row.kind === 'subagent')
2770
+ continue;
2462
2771
  if (row.kind === 'tool') {
2463
2772
  const bodyRows = wrappedToolBodyLineCount(toolBodyLines(row, Number.MAX_SAFE_INTEGER), width);
2464
2773
  if (!toolBodyFitsWorkspace(bodyRows, workspace))
@@ -2511,12 +2820,15 @@ export class SshTui {
2511
2820
  revealRow(row) {
2512
2821
  if (row === undefined)
2513
2822
  return;
2823
+ if (row.kind === 'subagent') {
2824
+ this.focusedRow = row;
2825
+ this.openSubagentInspect(row);
2826
+ return;
2827
+ }
2514
2828
  if (row.kind === 'tool') {
2515
2829
  const width = Math.max(10, this.screenColumns());
2516
2830
  const height = Math.max(6, this.screenRows());
2517
- const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
2518
- const bodyRows = wrappedToolBodyLineCount(body, width);
2519
- if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
2831
+ if (this.toolBodyTruncated(row, width, height)) {
2520
2832
  this.focusedRow = row;
2521
2833
  this.openToolInspect(row);
2522
2834
  return;
@@ -2688,7 +3000,7 @@ export class SshTui {
2688
3000
  const header = t('reason.done', { marker, lines }) + (row.expanded ? '' : t('card.expand'));
2689
3001
  const line = `${focused ? '▶ ' : ' '}${header}`;
2690
3002
  const styled = this.styleLine('reasoning', line);
2691
- addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
3003
+ addDisplay(this.selectLine(styled, focused), row);
2692
3004
  if (row.expanded) {
2693
3005
  for (const wrapped of wrap(row.text, width)) {
2694
3006
  addDisplay(this.styleLine('reasoning', wrapped), row);
@@ -2719,13 +3031,13 @@ export class SshTui {
2719
3031
  const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
2720
3032
  const styled = headerSegments.length === 0
2721
3033
  ? collapsed
2722
- : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
2723
- addDisplay(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);
2724
3036
  continue;
2725
3037
  }
2726
3038
  const expandedHeaderLines = headerSegments.length === 0
2727
3039
  ? wrap(header.plain, width)
2728
- : wrapSegmented(header.plain, Math.max(1, width), headerSegments);
3040
+ : wrapSegmented(header.plain, Math.max(1, width), headerSegments, this.colorDepth);
2729
3041
  for (const wrapped of expandedHeaderLines) {
2730
3042
  addDisplay(wrapped, row);
2731
3043
  }
@@ -2744,46 +3056,24 @@ export class SshTui {
2744
3056
  }
2745
3057
  if (row.kind === 'subagent') {
2746
3058
  const running = row.status === 'running';
2747
- const ok = row.status === 'ok';
2748
- const aborted = row.status === 'aborted';
2749
- const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : aborted ? '33' : '31';
2750
- const styleHeader = (line) => {
2751
- const safe = sanitizeTerminalText(line);
2752
- if (!this.color)
2753
- return safe;
2754
- const dotIndex = safe.indexOf('●');
2755
- if (dotColor === undefined || dotIndex === -1)
2756
- return safe;
2757
- return `${safe.slice(0, dotIndex)}\x1b[${dotColor}m●\x1b[0m${safe.slice(dotIndex + 1)}`;
2758
- };
2759
- const spinner = running ? ` ${this.spinnerFrame()}` : '';
2760
- const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : t('card.expand')}`;
2761
- this.paintCollapsibleHeader(addDisplay, row, 'system', header, width, styleHeader);
2762
- if (row.expanded) {
2763
- addDisplay(this.styleLine('system', t('sub.cardSession', {
2764
- id: row.sessionId,
2765
- provider: row.provider,
2766
- external: row.local ? '' : t('sub.external'),
2767
- })), row);
2768
- if (row.stopReason !== undefined) {
2769
- addDisplay(this.styleLine('system', t('sub.stopReason', { reason: row.stopReason })), row);
2770
- }
2771
- if (row.logs.length === 0) {
2772
- addDisplay(this.styleLine('system', running ? t('sub.cardWait') : t('sub.cardEmpty')), row);
2773
- }
2774
- else {
2775
- for (const entry of row.logs) {
2776
- const kind = entry.kind === 'assistant'
2777
- ? 'assistant'
2778
- : entry.kind === 'result' && row.status === 'error'
2779
- ? 'error'
2780
- : 'system';
2781
- for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
2782
- addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
2783
- }
2784
- }
2785
- }
2786
- }
3059
+ const elapsed = Math.max(0, Math.floor(((row.endedAt ?? Date.now()) - row.startedAt) / 1000));
3060
+ const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
3061
+ const header = buildSubagentHeader({
3062
+ focused: this.focusedRow === row,
3063
+ title: subagentDisplayName(row),
3064
+ status: row.status,
3065
+ elapsedLabel,
3066
+ summary: subagentChipSummary(row),
3067
+ spinner: running ? ` ${this.spinnerFrame()}` : '',
3068
+ inspectHint: t('card.inspect'),
3069
+ foreign: subagentProviderDiffers(this.currentProviderId(), row.modelProvider),
3070
+ });
3071
+ const headerSegments = this.color ? header.segments : [];
3072
+ const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
3073
+ const styled = headerSegments.length === 0
3074
+ ? this.styleLine('subagent-header', collapsed)
3075
+ : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments, this.colorDepth);
3076
+ addDisplay(this.selectLine(styled, this.focusedRow === row), row);
2787
3077
  continue;
2788
3078
  }
2789
3079
  if (row.kind === 'plan') {
@@ -2914,7 +3204,7 @@ export class SshTui {
2914
3204
  + (elapsed > 0 ? t('reason.elapsed', { seconds: elapsed }) : '');
2915
3205
  const line = `${focused ? '▶ ' : ' '}${header}`;
2916
3206
  const styled = this.styleLine('reasoning', line);
2917
- addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
3207
+ addDisplay(this.selectLine(styled, focused), block);
2918
3208
  if (block.expanded) {
2919
3209
  for (const wrapped of wrap(this.streaming.reasoning, width)) {
2920
3210
  addDisplay(this.styleLine('reasoning', wrapped), block);
@@ -3033,8 +3323,50 @@ export class SshTui {
3033
3323
  addDialog(t('onboard.ctrlF'));
3034
3324
  if (ob.providerType === 'catalog')
3035
3325
  addDialog(t('onboard.modelsCatalogHint'));
3326
+ addDialog(t('onboard.modelsPickHint'));
3036
3327
  addDialog(t('onboard.enterEsc'));
3037
3328
  break;
3329
+ case 'models-pick': {
3330
+ const candidates = ob.modelCandidates ?? [];
3331
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3332
+ addDialog(t('onboard.modelsPick', { count: candidates.length }));
3333
+ const start = pickerWindowStart(ob.modelCursor ?? 0, candidates.length);
3334
+ const end = Math.min(candidates.length, start + PICKER_WINDOW);
3335
+ if (start > 0)
3336
+ addDialog(` ${t('picker.moreAbove', { count: start })}`);
3337
+ for (let index = start; index < end; index += 1) {
3338
+ const id = candidates[index];
3339
+ if (id === undefined)
3340
+ continue;
3341
+ const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
3342
+ const mark = (ob.modelChecked ?? new Set()).has(index) ? '◉' : '○';
3343
+ addDialog(` ${focused} ${mark} ${id}`);
3344
+ }
3345
+ if (end < candidates.length)
3346
+ addDialog(` ${t('picker.moreBelow', { count: candidates.length - end })}`);
3347
+ addDialog(t('onboard.modelsCheckHint'));
3348
+ break;
3349
+ }
3350
+ case 'model-default': {
3351
+ const checked = this.checkedOnboardingModels(ob);
3352
+ addDialog(t('onboard.providerLine', { label: providerLabel }));
3353
+ addDialog(t('onboard.defaultModelPick'));
3354
+ const start = pickerWindowStart(ob.modelCursor ?? 0, checked.length);
3355
+ const end = Math.min(checked.length, start + PICKER_WINDOW);
3356
+ if (start > 0)
3357
+ addDialog(` ${t('picker.moreAbove', { count: start })}`);
3358
+ for (let index = start; index < end; index += 1) {
3359
+ const id = checked[index];
3360
+ if (id === undefined)
3361
+ continue;
3362
+ const focused = index === (ob.modelCursor ?? 0) ? '›' : ' ';
3363
+ addDialog(` ${focused} ○ ${id}`);
3364
+ }
3365
+ if (end < checked.length)
3366
+ addDialog(` ${t('picker.moreBelow', { count: checked.length - end })}`);
3367
+ addDialog(t('onboard.pickHint'));
3368
+ break;
3369
+ }
3038
3370
  case 'context':
3039
3371
  addDialog(t('onboard.providerLine', { label: providerLabel }));
3040
3372
  addDialog(t('onboard.contextPrompt'));
@@ -3053,6 +3385,9 @@ export class SshTui {
3053
3385
  api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
3054
3386
  }));
3055
3387
  addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
3388
+ if (ob.defaultModel !== undefined) {
3389
+ addDialog(t('onboard.confirmDefaultModel', { model: ob.defaultModel }));
3390
+ }
3056
3391
  if (ob.routeContextWindow !== undefined) {
3057
3392
  addDialog(t('onboard.confirmContext', { value: String(ob.routeContextWindow) }));
3058
3393
  }
@@ -3150,8 +3485,8 @@ export class SshTui {
3150
3485
  continue;
3151
3486
  const marker = index === this.suggestionIndex ? '›' : ' ';
3152
3487
  const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
3153
- suggestionLines.push(index === this.suggestionIndex && this.color
3154
- ? `\x1b[7m${fitLine(line)}\x1b[27m`
3488
+ suggestionLines.push(index === this.suggestionIndex
3489
+ ? this.selectLine(fitLine(line), true)
3155
3490
  : this.styleLine('system', fitLine(line)));
3156
3491
  }
3157
3492
  if (suggestionEnd < this.commandSuggestions.length) {
@@ -3257,7 +3592,7 @@ export class SshTui {
3257
3592
  ? formatFooterBalance(this.balanceSnapshot)
3258
3593
  : undefined;
3259
3594
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
3260
- const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
3595
+ const compacting = this.compactionRunning();
3261
3596
  const parentModel = current?.model ?? this.agent.options.model ?? '';
3262
3597
  const sub = this.subagentSelection.current;
3263
3598
  const footer = {
@@ -3281,7 +3616,7 @@ export class SshTui {
3281
3616
  provider,
3282
3617
  parentModel,
3283
3618
  subModel: sub.model,
3284
- ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
3619
+ ...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
3285
3620
  ...(sub.reasoningEffort === undefined ? {} : { subEffort: String(sub.reasoningEffort) }),
3286
3621
  // Quota and context pressure stay on the identity line, where they have
3287
3622
  // always been: B-1 moved them one row up into the strip, and a user
@@ -3318,11 +3653,18 @@ export class SshTui {
3318
3653
  : activity.text;
3319
3654
  const identity = footerIdentityParts(footer);
3320
3655
  const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
3321
- // The context ring is the one accent on an otherwise muted line; the mute
3322
- // has to be resumed after its reset, exactly as the pre-strip footer did.
3323
- const statusLine = this.contextPressure === undefined || !this.color
3324
- ? this.styleLine('system', statusText)
3325
- : 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
+ }
3326
3668
  // The strip: one loss order for the groups a narrow terminal can do without.
3327
3669
  // Health leads because it is the only group that reports a broken install;
3328
3670
  // the session counters go before the operational signals. Quota and context
@@ -3472,7 +3814,7 @@ export class SshTui {
3472
3814
  }
3473
3815
  currentSelectionLabel() {
3474
3816
  const current = this.selectionRef?.current;
3475
- const provider = this.currentProviderId();
3817
+ const provider = this.displayProviderId(this.currentProviderId());
3476
3818
  const model = current?.model ?? this.agent.options.model ?? 'unknown';
3477
3819
  const effort = current?.reasoningEffort;
3478
3820
  const kind = describeProviderRoute(provider).short;
@@ -3484,7 +3826,7 @@ export class SshTui {
3484
3826
  }
3485
3827
  /** Refresh the terminal window title (throttled while running). */
3486
3828
  updateTerminalTitle() {
3487
- if (this.exiting)
3829
+ if (this.exiting || this.lineMode)
3488
3830
  return;
3489
3831
  const now = Date.now();
3490
3832
  // Completion wins over a still-running agent status: the turn/end event
@@ -3495,16 +3837,20 @@ export class SshTui {
3495
3837
  this.write(t('title.done', { suffix: titleSuffix }));
3496
3838
  return;
3497
3839
  }
3498
- 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) {
3499
3845
  if (now - this.lastTitleUpdateAt < 800)
3500
3846
  return;
3501
3847
  this.lastTitleUpdateAt = now;
3502
3848
  const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
3503
- let detail = t('title.running');
3849
+ let detail = compacting ? t('title.compacting') : t('title.running');
3504
3850
  if (this.dialog?.kind === 'questions') {
3505
3851
  detail = planReviewOf(this.dialog.question) ? t('question.planTitle') : t('title.waitAnswer');
3506
3852
  }
3507
- else if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
3853
+ else if (compacting) {
3508
3854
  detail = t('title.compacting');
3509
3855
  }
3510
3856
  else if (this.activeSubagents.size > 0) {
@@ -3530,6 +3876,8 @@ export class SshTui {
3530
3876
  }
3531
3877
  /** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */
3532
3878
  playCompletionSignal() {
3879
+ if (this.lineMode)
3880
+ return;
3533
3881
  const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
3534
3882
  if (disabled)
3535
3883
  return;
@@ -3991,15 +4339,50 @@ export class SshTui {
3991
4339
  kind === 'todo-skipped' ? '2;33' :
3992
4340
  kind === 'todo-pending' ? '90' :
3993
4341
  kind === 'plan-dock' ? '38;5;180' :
3994
- kind === 'diag' ? '2;36' :
3995
- kind === 'error' ? '31' :
3996
- '90';
4342
+ kind === 'subagent-header' ? '38;5;141' :
4343
+ kind === 'diag' ? '2;36' :
4344
+ kind === 'error' ? '31' :
4345
+ '90';
3997
4346
  const code = downgradeSgr(requested, this.colorDepth);
3998
4347
  // Nothing left to paint: the text stands on its own.
3999
4348
  if (code === '')
4000
4349
  return safe;
4001
4350
  return `\x1b[${code}m${safe}\x1b[0m`;
4002
4351
  }
4352
+ /**
4353
+ * One emphasised piece of a body line — the words a diff changed.
4354
+ *
4355
+ * Inside a filled diff row the mark is a lighter shade of that same fill.
4356
+ * Inverse video was there first, and it inverted the *fill*: a line replaced
4357
+ * wholesale is emphasised from end to end, so it came out a bright block
4358
+ * beside rows that kept the muted tone — visibly from another palette. On a
4359
+ * terminal with no colour left to shift, the attribute is what survives, so
4360
+ * the inverse video stays for that case.
4361
+ */
4362
+ styleEmphasisedPiece(kind, text) {
4363
+ const requested = DIFF_EMPHASIS_SGR[kind];
4364
+ if (requested !== undefined && this.color) {
4365
+ const code = downgradeSgr(requested, this.colorDepth);
4366
+ if (code !== '')
4367
+ return `\x1b[${code}m${sanitizeTerminalText(text)}\x1b[0m`;
4368
+ }
4369
+ return `\x1b[7m${this.styleLine(kind, text)}\x1b[27m`;
4370
+ }
4371
+ /**
4372
+ * Paint a row the reader has selected.
4373
+ *
4374
+ * The obvious `7`…`27` wrap only reaches the part before the line's first
4375
+ * reset, and every painted segment ends with one: a focused card kept its
4376
+ * marker and status dot highlighted while the title, the state word and the
4377
+ * summary stayed plain, so ↑/↓ gave no visible clue which card was picked —
4378
+ * on exactly the rows that carry a segmented header. Re-opening the attribute
4379
+ * after every reset covers the whole row, whatever colours sit on top of it.
4380
+ */
4381
+ selectLine(styled, selected) {
4382
+ if (!selected || !this.color)
4383
+ return styled;
4384
+ return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
4385
+ }
4003
4386
  /**
4004
4387
  * Fold one live or durable stream chunk into the in-progress assistant
4005
4388
  * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
@@ -4072,6 +4455,12 @@ export class SshTui {
4072
4455
  * The parent request is left untouched (its own `/model` waterfall already
4073
4456
  * owns the route); direct children created by tool-subagent inherit the
4074
4457
  * parent provider unless `/submodel` stored an explicit subagent provider.
4458
+ *
4459
+ * Effort is not inherited from the parent. `next()` copies the live parent
4460
+ * config, so a grok-4.6 `xhigh` parent would otherwise send `xhigh` with
4461
+ * the light grok-4.5 child and fail with UNSUPPORTED_REASONING_EFFORT
4462
+ * before the child writes a closing message. An explicit `/subeffort`
4463
+ * still wins; otherwise the child's own adapter default is used.
4075
4464
  */
4076
4465
  handleAgentRequest = async ({ agent }, next) => {
4077
4466
  const resolved = await next();
@@ -4080,14 +4469,23 @@ export class SshTui {
4080
4469
  const selection = this.subagentSelection.current;
4081
4470
  const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
4082
4471
  const provider = selection.provider ?? parentProvider;
4083
- 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)
4084
4476
  ? selection.model
4085
4477
  : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
4478
+ const { reasoningEffort: parentEffort, ...withoutParentEffort } = resolved;
4479
+ // `/subeffort` wins. Otherwise keep the parent effort only when the child
4480
+ // is still on that same model — grok-4.6 xhigh is legal on grok-4.6 and
4481
+ // rejected on grok-4.5.
4482
+ const effort = selection.reasoningEffort
4483
+ ?? (model === resolved.model ? parentEffort : undefined);
4086
4484
  return {
4087
- ...resolved,
4485
+ ...withoutParentEffort,
4088
4486
  provider,
4089
4487
  model,
4090
- ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }),
4488
+ ...(effort === undefined ? {} : { reasoningEffort: effort }),
4091
4489
  };
4092
4490
  };
4093
4491
  handleSessionEvent = (session, event) => {
@@ -4217,6 +4615,30 @@ export class SshTui {
4217
4615
  this.statsTracker.noteToolStart(String(event.data.callId), event.time);
4218
4616
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
4219
4617
  const present = presentToolCall(event.data.name, event.data.arguments);
4618
+ if (SUBAGENT_TOOL_NAMES.has(event.data.name)) {
4619
+ const task = present.summary.trim();
4620
+ const callId = String(event.data.callId);
4621
+ // Replay never gets `subagent/start` to drain this queue, so only a
4622
+ // live spawn waits for its child card. The pair is dropped again at
4623
+ // `tool/result` when the spawn never started a child (denied,
4624
+ // cancelled): a description left behind would name the next child.
4625
+ if (task !== '' && !this.replaying)
4626
+ this.pendingSubagentTasks.push({ task, callId });
4627
+ if (this.replaying && !this.rows.some(candidate => candidate.kind === 'subagent' && candidate.runId === callId)) {
4628
+ // Live `subagent/start` is not in the parent log. Resume still
4629
+ // needs a courtesy chip; a live Host already has the real card.
4630
+ this.pushRow(subagentRowFromSpawnTool({
4631
+ callId,
4632
+ task,
4633
+ modelProvider: this.currentProviderId(),
4634
+ status: 'running',
4635
+ startedAt: typeof event.time === 'number' ? event.time : Date.now(),
4636
+ }));
4637
+ }
4638
+ this.streaming = undefined;
4639
+ this.markDirty();
4640
+ break;
4641
+ }
4220
4642
  const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
4221
4643
  if (previous !== undefined) {
4222
4644
  this.mergeIntoToolCard(previous, {
@@ -4259,8 +4681,30 @@ export class SshTui {
4259
4681
  this.openToolCalls.delete(String(event.data.message.source.callId));
4260
4682
  this.statsTracker.noteToolEnd(String(event.data.message.source.callId), event.time);
4261
4683
  const callId = String(event.data.message.source.callId);
4262
- 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));
4263
4689
  const output = collectText(event.data.message.content);
4690
+ if (spawnCard !== undefined) {
4691
+ // A live child already owns this spawn: the parent tool/result is
4692
+ // just the wrapper settling. Do not stamp a 0s twin chip complete.
4693
+ if (spawnCard.childSessionId !== undefined) {
4694
+ this.markDirty();
4695
+ break;
4696
+ }
4697
+ const failed = event.data.error !== undefined || event.data.message.content[0]?.isError === true;
4698
+ spawnCard.status = failed ? 'error' : 'ok';
4699
+ spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
4700
+ if (output !== '') {
4701
+ spawnCard.lastActivity = clipSubagentActivity(output, 80);
4702
+ appendSubagentLog(spawnCard, { kind: failed ? 'result' : 'assistant', text: clipSubagentActivity(output, 80) });
4703
+ }
4704
+ this.markDirty();
4705
+ break;
4706
+ }
4707
+ const row = this.findToolRowByCallId(callId);
4264
4708
  if (row !== undefined) {
4265
4709
  const metaDiffs = diffMetaDiffs(event.data.meta);
4266
4710
  if (metaDiffs !== null) {
@@ -4295,11 +4739,14 @@ export class SshTui {
4295
4739
  ?? (typeof sourceName === 'string' ? sourceName : '');
4296
4740
  if (recordedName !== '' && HIDDEN_TOOL_NAMES.has(recordedName))
4297
4741
  break;
4742
+ // A spawn is drawn as the child's chip, never as a `subagent` tool
4743
+ // card: without a start there is still no card to settle, and a
4744
+ // second card beside the chip is exactly the duplicate to avoid.
4745
+ if (SUBAGENT_TOOL_NAMES.has(recordedName))
4746
+ break;
4298
4747
  // Never title a card with the call id (`call-<uuid>`). Prefer the
4299
4748
  // recorded tool name; fall back to a generic tool card.
4300
- const toolName = recordedName === '' || recordedName.startsWith('call-')
4301
- ? 'tool'
4302
- : recordedName;
4749
+ const toolName = displayToolName(recordedName);
4303
4750
  const present = presentToolCall(toolName, '');
4304
4751
  this.pushRow({
4305
4752
  kind: 'tool',
@@ -4348,6 +4795,9 @@ export class SshTui {
4348
4795
  this.stalledWarningShown = false;
4349
4796
  this.llmRetry = undefined;
4350
4797
  this.status = `turn ${event.data.turn} running`;
4798
+ // A turn is about to spend on this route: make sure the session's record
4799
+ // names it, whatever changed it (a preset, a settings edit, a resume).
4800
+ this.noteSessionRoute();
4351
4801
  this.markDirty();
4352
4802
  break;
4353
4803
  case 'turn/end': {
@@ -4356,6 +4806,10 @@ export class SshTui {
4356
4806
  this.toolCallNames.clear();
4357
4807
  this.statsTracker.noteTurnEnd();
4358
4808
  this.stalledWarningShown = false;
4809
+ // A retry belongs to the turn that scheduled it: once the turn closes,
4810
+ // the chip must not outlive it (a failed retry would otherwise leave
4811
+ // "重试 n/m" on an idle footer until the next turn).
4812
+ this.llmRetry = undefined;
4359
4813
  this.pendingMessages.clear();
4360
4814
  // Aborted/errored turns may close without an assembled
4361
4815
  // assistant/message; never leave a half-streamed thinking block behind.
@@ -4530,6 +4984,12 @@ export class SshTui {
4530
4984
  if (this.llmRetry !== undefined) {
4531
4985
  this.pushRow({ kind: 'system', text: t('retry.started', { retry: this.llmRetry.retry }) });
4532
4986
  }
4987
+ // The backoff is over and the retried request is in flight: the footer
4988
+ // must go back to the ordinary running/waiting states instead of holding
4989
+ // "retry n/m" until the next turn starts (which could be minutes later,
4990
+ // or never — and a compaction ending in between fell back to this stale
4991
+ // chip rather than to 运行中).
4992
+ this.llmRetry = undefined;
4533
4993
  this.markDirty();
4534
4994
  return;
4535
4995
  }
@@ -4552,7 +5012,54 @@ export class SshTui {
4552
5012
  if (named !== undefined)
4553
5013
  return named;
4554
5014
  }
4555
- 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();
4556
5063
  }
4557
5064
  handleCompactionEvent(type, event) {
4558
5065
  const payload = event.data;
@@ -4663,11 +5170,14 @@ export class SshTui {
4663
5170
  canRunCompactCommand() {
4664
5171
  if (this.replaying || this.agentGone || this.exiting)
4665
5172
  return false;
5173
+ // A card left running by a Host that died would otherwise refuse /compact
5174
+ // for the rest of the session; its age decides whether it is still real.
5175
+ this.settleUnfinishedCompactions({ staleOnly: true });
4666
5176
  if (this.agent.status === 'running')
4667
5177
  return false;
4668
5178
  if (this.idleCompactInFlight)
4669
5179
  return false;
4670
- if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
5180
+ if (this.compactionRunning())
4671
5181
  return false;
4672
5182
  return this.ctx.get('commands') !== undefined;
4673
5183
  }
@@ -4687,8 +5197,13 @@ export class SshTui {
4687
5197
  this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
4688
5198
  return;
4689
5199
  }
5200
+ // A card left running by a Host that died is closed here rather than
5201
+ // refusing the user for the rest of the session: its age decides whether it
5202
+ // is still real. The footer already ignores a stale card, but the card
5203
+ // itself must stop rendering as running too.
5204
+ this.settleUnfinishedCompactions({ staleOnly: true });
4690
5205
  if (this.agent.status === 'running'
4691
- || this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
5206
+ || this.compactionRunning()) {
4692
5207
  if (reason === 'user')
4693
5208
  this.pushRow({ kind: 'error', text: t('compact.busy') });
4694
5209
  this.markDirty();
@@ -4719,7 +5234,7 @@ export class SshTui {
4719
5234
  this.pushRow({ kind: 'error', text: t('cmd.unknown', { command: 'compact' }) });
4720
5235
  return;
4721
5236
  }
4722
- const compactionRunning = () => this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
5237
+ const compactionRunning = () => this.compactionRunning();
4723
5238
  const releaseIfSettled = () => {
4724
5239
  queueMicrotask(() => {
4725
5240
  if (!compactionRunning())
@@ -4820,7 +5335,7 @@ export class SshTui {
4820
5335
  if (this.status.startsWith(t('compact.short')) || this.status.startsWith('compact')) {
4821
5336
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
4822
5337
  }
4823
- if (!this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
5338
+ if (!this.compactionRunning()) {
4824
5339
  this.idleCompactInFlight = false;
4825
5340
  }
4826
5341
  this.markDirty();
@@ -4908,35 +5423,72 @@ export class SshTui {
4908
5423
  switch (event.type) {
4909
5424
  case 'user/message': {
4910
5425
  const text = collectText(event.data.content);
4911
- if (text !== '')
4912
- 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);
4913
5430
  break;
4914
5431
  }
4915
5432
  case 'assistant/message': {
4916
5433
  const text = collectText(event.data.message.content);
5434
+ // Long enough to be worth opening the overlay for; the collapsed chip
5435
+ // reads `lastActivity`, not this entry.
4917
5436
  if (text !== '')
4918
- appendSubagentLog(row, { kind: 'assistant', text: truncate(text, 8) });
5437
+ appendSubagentLog(row, { kind: 'assistant', text: clipSubagentActivity(text, 200) });
4919
5438
  break;
4920
5439
  }
4921
5440
  case 'tool/call': {
4922
5441
  if (HIDDEN_TOOL_NAMES.has(event.data.name))
4923
5442
  break;
5443
+ this.toolCallNames.set(String(event.data.callId), event.data.name);
4924
5444
  const present = presentToolCall(event.data.name, event.data.arguments);
4925
- appendSubagentLog(row, { 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
+ });
4926
5450
  break;
4927
5451
  }
4928
5452
  case 'tool/result': {
4929
- const output = truncate(collectText(event.data.message.content), 3);
4930
5453
  const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
5454
+ const callId = String(event.data.message.source.callId);
5455
+ const sourceName = event.data.message.source.name;
5456
+ const recorded = this.toolCallNames.get(callId)
5457
+ ?? (typeof sourceName === 'string' ? sourceName : '');
5458
+ const title = toolTitle(displayToolName(recorded));
5459
+ // The overlay is the only place a child's tool output can be read, so
5460
+ // keep a few lines of it — the call line itself carries the title.
5461
+ const output = truncate(collectText(event.data.message.content), 3).trim();
4931
5462
  appendSubagentLog(row, {
4932
5463
  kind: 'result',
4933
- text: `${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : ` · ${output}`}`,
5464
+ text: `${ok ? '✓' : '✗'} ${title}`,
5465
+ ...(output === '' ? {} : { detail: output }),
5466
+ callId,
4934
5467
  });
4935
5468
  break;
4936
5469
  }
4937
- case 'turn/end':
4938
- 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
+ });
4939
5490
  break;
5491
+ }
4940
5492
  case 'approval/asked':
4941
5493
  appendSubagentLog(row, { kind: 'approval', text: t('sub.approval', { tool: event.data.toolName }) });
4942
5494
  break;
@@ -4945,6 +5497,7 @@ export class SshTui {
4945
5497
  break;
4946
5498
  }
4947
5499
  this.lastActivity = Date.now();
5500
+ this.refreshOpenSubagentInspect(row);
4948
5501
  this.markDirty();
4949
5502
  };
4950
5503
  handleSubagentStart = (info) => {
@@ -4956,31 +5509,53 @@ export class SshTui {
4956
5509
  });
4957
5510
  this.subagentSessions.add(sessionId);
4958
5511
  this.lastActivity = Date.now();
5512
+ // The spawn description is a FIFO pair (task + parent call id). A card that
5513
+ // is already linked to a spawn keeps it: only a fresh child may take the
5514
+ // oldest waiting description, or two parallel spawns swap names.
4959
5515
  const existing = this.findSubagentRow(sessionId);
5516
+ const pending = existing?.spawnCallId === undefined ? this.pendingSubagentTasks.shift() : undefined;
5517
+ const task = pending?.task;
5518
+ const modelProvider = this.subagentSelection.current.provider ?? this.currentProviderId();
5519
+ const startedText = t('sub.startedDetail', {
5520
+ provider: modelProvider,
5521
+ external: info.local ? '' : t('sub.external'),
5522
+ });
4960
5523
  if (existing !== undefined) {
5524
+ existing.childSessionId = sessionId;
4961
5525
  existing.runId = String(info.runId);
4962
5526
  existing.provider = info.provider;
5527
+ existing.modelProvider = modelProvider;
4963
5528
  existing.local = info.local;
4964
5529
  existing.status = 'running';
4965
5530
  existing.startedAt = Date.now();
4966
5531
  existing.endedAt = undefined;
4967
5532
  existing.stopReason = undefined;
5533
+ delete existing.failHint;
4968
5534
  existing.lastActivity = t('sub.started');
4969
5535
  existing.expanded = false;
4970
- 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);
4971
5542
  }
4972
5543
  else {
4973
5544
  this.pushRow({
4974
5545
  kind: 'subagent',
4975
5546
  sessionId,
5547
+ childSessionId: sessionId,
5548
+ ...(pending === undefined ? {} : { spawnCallId: pending.callId }),
4976
5549
  runId: String(info.runId),
4977
5550
  provider: info.provider,
5551
+ modelProvider,
4978
5552
  local: info.local,
4979
5553
  label: t('sub.label', { provider: info.provider }),
5554
+ ...(task === undefined ? {} : { task }),
4980
5555
  status: 'running',
4981
5556
  startedAt: Date.now(),
4982
5557
  lastActivity: t('sub.started'),
4983
- logs: [{ kind: 'system', text: t('sub.startedDetail', { provider: info.provider, external: info.local ? '' : t('sub.external') }) }],
5558
+ logs: [{ kind: 'system', text: startedText }],
4984
5559
  expanded: false,
4985
5560
  });
4986
5561
  }
@@ -4992,37 +5567,79 @@ export class SshTui {
4992
5567
  this.lastActivity = Date.now();
4993
5568
  const output = info.lastAssistantMessage === undefined
4994
5569
  ? ''
4995
- : truncate(collectText(info.lastAssistantMessage), 6);
4996
- 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);
4997
5579
  const failed = info.stopReason !== 'completed';
5580
+ const modelProvider = row?.modelProvider
5581
+ ?? this.subagentSelection.current.provider
5582
+ ?? this.currentProviderId();
5583
+ const explained = failed
5584
+ ? describeSubagentFailure({
5585
+ stopReason: info.stopReason,
5586
+ message: output,
5587
+ provider: modelProvider,
5588
+ })
5589
+ : undefined;
5590
+ // A healthy end clears any hint an earlier failed attempt left on the row.
5591
+ const hint = explained?.hint;
5592
+ const endText = hint === undefined
5593
+ ? t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`)
5594
+ : hint;
4998
5595
  if (row !== undefined) {
4999
5596
  row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
5000
5597
  row.endedAt = Date.now();
5001
5598
  row.stopReason = info.stopReason;
5599
+ row.childSessionId = String(info.id);
5600
+ row.modelProvider = modelProvider;
5601
+ if (hint === undefined)
5602
+ delete row.failHint;
5603
+ else
5604
+ row.failHint = hint;
5002
5605
  appendSubagentLog(row, {
5003
5606
  kind: failed ? 'result' : 'assistant',
5004
- text: t('sub.ended', { reason: info.stopReason }) + (output === '' ? '' : ` · ${output}`),
5607
+ text: endText,
5005
5608
  });
5609
+ this.refreshOpenSubagentInspect(row);
5006
5610
  }
5007
5611
  else {
5008
5612
  this.pushRow({
5009
5613
  kind: 'subagent',
5010
5614
  sessionId: String(info.id),
5615
+ childSessionId: String(info.id),
5011
5616
  runId: String(info.runId),
5012
5617
  provider: info.provider,
5618
+ modelProvider,
5013
5619
  local: info.local,
5014
5620
  label: t('sub.label', { provider: info.provider }),
5015
5621
  status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
5016
5622
  startedAt: Date.now(),
5017
5623
  endedAt: Date.now(),
5018
5624
  stopReason: info.stopReason,
5019
- lastActivity: t('sub.ended', { reason: info.stopReason }),
5020
- 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 }],
5021
5628
  expanded: false,
5022
5629
  });
5023
5630
  }
5024
5631
  this.markDirty();
5025
5632
  };
5633
+ /** Keep an open inspect overlay in sync with the live child log. */
5634
+ refreshOpenSubagentInspect(row) {
5635
+ const dialog = this.dialog;
5636
+ if (dialog === undefined || dialog.kind !== 'inspect')
5637
+ return;
5638
+ if (dialog.subagentSessionId !== this.subagentInspectId(row))
5639
+ return;
5640
+ dialog.title = t('sub.inspectTitle', { title: subagentDisplayName(row) });
5641
+ dialog.lines = subagentInspectLines(row);
5642
+ }
5026
5643
  // ── approval and questions ──────────────────────────────────────────────
5027
5644
  hasLiveDisplay() {
5028
5645
  if (this.disposed || this.exiting)
@@ -5112,7 +5729,7 @@ export class SshTui {
5112
5729
  const selection = this.subagentSelection.current;
5113
5730
  const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
5114
5731
  const provider = selection.provider ?? parentProvider;
5115
- const model = subagentModelMatchesProvider(provider, selection.model)
5732
+ const model = selection.provider !== undefined || subagentModelMatchesProvider(provider, selection.model)
5116
5733
  ? selection.model
5117
5734
  : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
5118
5735
  return { provider, model };
@@ -5338,9 +5955,7 @@ export class SshTui {
5338
5955
  return 'cancelled';
5339
5956
  }
5340
5957
  }
5341
- const agentLabel = request.agent.id === this.agent.id
5342
- ? t('approval.thisSession')
5343
- : t('sub.agentLabel', { id: request.agent.id });
5958
+ const agentLabel = t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
5344
5959
  return new Promise((resolve) => {
5345
5960
  if (request.signal?.aborted === true) {
5346
5961
  resolve('cancelled');
@@ -5378,7 +5993,7 @@ export class SshTui {
5378
5993
  const answers = [];
5379
5994
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
5380
5995
  ? undefined
5381
- : t('sub.agentLabel', { id: request.agent.id });
5996
+ : t('sub.agentLabel', { name: this.subagentNameFor(String(request.agent.id)) });
5382
5997
  const cards = [];
5383
5998
  for (const question of request.questions) {
5384
5999
  const card = {
@@ -5455,6 +6070,7 @@ export class SshTui {
5455
6070
  openDialog(dialog) {
5456
6071
  if (this.dialog === undefined) {
5457
6072
  this.dialog = dialog;
6073
+ this.echoLineModeDialog(dialog);
5458
6074
  }
5459
6075
  else {
5460
6076
  this.dialogQueue.push(dialog);
@@ -5467,9 +6083,53 @@ export class SshTui {
5467
6083
  const next = this.dialogQueue.shift();
5468
6084
  if (next !== undefined) {
5469
6085
  this.dialog = next;
6086
+ this.echoLineModeDialog(next);
5470
6087
  this.markDirty();
5471
6088
  }
5472
6089
  }
6090
+ /**
6091
+ * Line mode has no framed dialog. Write the prompt (and its keys) into the
6092
+ * log once when it becomes active, so a `tee` or a screen reader can answer
6093
+ * it. Options stay on the framed path; the log only needs the question.
6094
+ */
6095
+ echoLineModeDialog(dialog) {
6096
+ if (!this.lineMode)
6097
+ return;
6098
+ if (dialog.kind === 'confirm') {
6099
+ this.pushRow({ kind: 'system', text: dialog.prompt });
6100
+ this.pushRow({ kind: 'system', text: dialog.hint });
6101
+ return;
6102
+ }
6103
+ if (dialog.kind === 'questions') {
6104
+ const header = t('dialog.ask', {
6105
+ index: dialog.index + 1,
6106
+ total: dialog.total,
6107
+ question: dialog.question.question,
6108
+ });
6109
+ this.pushRow({ kind: 'system', text: header });
6110
+ if (dialog.question.header !== undefined && dialog.question.header !== '') {
6111
+ this.pushRow({ kind: 'system', text: dialog.question.header });
6112
+ }
6113
+ const options = dialog.question.options ?? [];
6114
+ for (const [index, option] of options.entries()) {
6115
+ if (option === undefined)
6116
+ continue;
6117
+ const key = QUESTION_OPTION_KEYS[index] ?? String(index + 1);
6118
+ const extra = option.description === undefined ? '' : ` — ${option.description}`;
6119
+ this.pushRow({ kind: 'system', text: ` ${key} ${option.label}${extra}` });
6120
+ }
6121
+ this.pushRow({
6122
+ kind: 'system',
6123
+ text: options.length === 0
6124
+ ? t('dialog.freeform')
6125
+ : dialog.question.multiSelect === true ? t('dialog.multiHint') : t('dialog.singleHint'),
6126
+ });
6127
+ return;
6128
+ }
6129
+ if (dialog.kind === 'onboarding') {
6130
+ this.pushRow({ kind: 'system', text: t('onboard.title') });
6131
+ }
6132
+ }
5473
6133
  removeQueuedDialog(dialog) {
5474
6134
  const index = this.dialogQueue.indexOf(dialog);
5475
6135
  if (index !== -1)
@@ -5552,43 +6212,281 @@ export class SshTui {
5552
6212
  const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5553
6213
  return section?.providers?.[provider];
5554
6214
  }
5555
- /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
5556
- openCodeListingBaseURL(provider) {
5557
- if (provider === 'opencode-go')
5558
- return providerTemplates()['opencode-go'].defaultBaseUrl;
5559
- if (provider === 'opencode')
5560
- return OPENCODE_ZEN_BASE_URL;
5561
- return undefined;
5562
- }
5563
6215
  /**
5564
- * Fetch the live model list for an OpenCode or third-party provider from its
5565
- * OpenAI-compatible listing endpoint. The provider route is deliberately not
5566
- * passed to discovery: pi-ai would answer a catalog route from its installed
5567
- * 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.
5568
6232
  */
5569
- async discoverEndpointModels(provider) {
5570
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
5571
- const profile = this.piAiProviderProfile(provider);
5572
- const source = openCodeSourceFor(provider, llmPiAi);
5573
- const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
5574
- ? profile.baseURL.trim()
5575
- : this.openCodeListingBaseURL(provider);
5576
- if (baseURL === undefined)
6233
+ providerFamilyRows(provider) {
6234
+ if (provider === '')
5577
6235
  return [];
5578
- const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
5579
- const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
5580
- ? profile.apiKeyEnv.trim()
5581
- : source?.apiKeyEnv;
5582
- const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
5583
- const llm = this.ctx.get('llm');
5584
- if (llm === undefined)
6236
+ const base = baseProviderIdOf(provider);
6237
+ const baseRow = this.piAiProviderProfile(base);
6238
+ const rows = [base];
6239
+ for (const protocol of GATEWAY_PROTOCOL_PREFERENCE) {
6240
+ const sibling = siblingProviderId(base, protocol);
6241
+ if (sibling === provider || rows.includes(sibling)) {
6242
+ if (this.isFamilyRow(baseRow, sibling, protocol))
6243
+ rows.push(sibling);
6244
+ continue;
6245
+ }
6246
+ if (this.isFamilyRow(baseRow, sibling, protocol))
6247
+ rows.push(sibling);
6248
+ }
6249
+ const present = rows.filter(row => this.piAiProviderProfile(row) !== undefined);
6250
+ if (present.length <= 1) {
6251
+ // A lone sibling is not a family — it is just this provider.
6252
+ return baseRow !== undefined || present.includes(provider) ? present : [provider];
6253
+ }
6254
+ return present;
6255
+ }
6256
+ /**
6257
+ * Whether `sibling` is genuinely the same gateway as `base`, on another
6258
+ * protocol: its `api` matches its suffix, its endpoint matches the base row's,
6259
+ * and the two labels agree when both are set.
6260
+ */
6261
+ isFamilyRow(baseRow, sibling, protocol) {
6262
+ if (baseRow === undefined)
6263
+ return false;
6264
+ const profile = this.piAiProviderProfile(sibling);
6265
+ if (profile === undefined)
6266
+ return false;
6267
+ const endpoint = (value) => typeof value === 'string' ? value.replace(/\/+$/u, '').toLowerCase() : '';
6268
+ const api = declaredProtocol(profile.api);
6269
+ if (api !== undefined && api !== protocol)
6270
+ return false;
6271
+ const baseUrl = endpoint(baseRow.baseURL);
6272
+ const siblingUrl = endpoint(profile.baseURL);
6273
+ if (baseUrl !== '' && siblingUrl !== '' && baseUrl !== siblingUrl)
6274
+ return false;
6275
+ const baseLabel = typeof baseRow.displayName === 'string' ? baseRow.displayName.trim() : '';
6276
+ const siblingLabel = typeof profile.displayName === 'string' ? profile.displayName.trim() : '';
6277
+ if (baseLabel !== '' && siblingLabel !== '' && baseLabel !== siblingLabel)
6278
+ return false;
6279
+ return true;
6280
+ }
6281
+ /** The id the TUI shows for a route: its family's base when it is spread. */
6282
+ displayProviderId(provider) {
6283
+ const rows = this.providerFamilyRows(provider);
6284
+ return rows.length > 1 ? (rows[0] ?? provider) : provider;
6285
+ }
6286
+ /** The models one row configures, as ids with the labels settings carry. */
6287
+ configuredModelsOf(row) {
6288
+ const models = this.piAiProviderProfile(row)?.models;
6289
+ if (!Array.isArray(models))
5585
6290
  return [];
5586
- const discovered = await discoverProviderModels(llm, {
5587
- baseURL,
5588
- ...(api === undefined ? {} : { api }),
5589
- ...(apiKey === undefined ? {} : { apiKey }),
5590
- }, AbortSignal.timeout(15_000));
5591
- return discovered.map(model => ({ id: model.id, label: model.name || model.id }));
6291
+ const out = [];
6292
+ for (const raw of models) {
6293
+ if (typeof raw === 'string') {
6294
+ if (raw !== '')
6295
+ out.push({ id: raw });
6296
+ continue;
6297
+ }
6298
+ if (raw === null || typeof raw !== 'object')
6299
+ continue;
6300
+ const entry = raw;
6301
+ if (typeof entry.id !== 'string' || entry.id === '')
6302
+ continue;
6303
+ out.push({
6304
+ id: entry.id,
6305
+ ...(typeof entry.name === 'string' && entry.name !== '' ? { label: entry.name } : {}),
6306
+ });
6307
+ }
6308
+ return out;
6309
+ }
6310
+ /**
6311
+ * The protocols the gateway publishes for one model, best-effort.
6312
+ *
6313
+ * Used when a model is being filed but no picker listed it yet (`/model <id>`
6314
+ * on a new id, a `--model` override, a `/submodel` pin): the gateway's own
6315
+ * answer is the only thing that can put it on a route that can send it.
6316
+ * Any failure is "no opinion", never a blocked selection.
6317
+ */
6318
+ async advertisedFor(family, model) {
6319
+ const rows = this.providerFamilyRows(family);
6320
+ if (rows.length <= 1)
6321
+ return [];
6322
+ // A model the last listing already filed needs no second opinion, and that
6323
+ // is the common case: `/model` re-picking a listed model must never wait on
6324
+ // the gateway again. Only an unlisted id (a hand-typed one, a `--model`
6325
+ // override) asks, and even then the wait is bounded — an unreachable gateway
6326
+ // must not stall a selection for the endpoint's full timeout.
6327
+ if (this.familyModelOwner.has(`${family}\u0000${model}`))
6328
+ return [];
6329
+ if (this.configuredModelsOf(rows[0] ?? family).some(entry => entry.id === model))
6330
+ return [];
6331
+ try {
6332
+ const listing = await this.discoverGatewayListing(rows[0] ?? family, AbortSignal.timeout(5_000));
6333
+ return advertisedProtocols(listing.endpoints, model);
6334
+ }
6335
+ catch {
6336
+ return [];
6337
+ }
6338
+ }
6339
+ /**
6340
+ * The row of a gateway family that serves `model`: the one learned from the
6341
+ * last listing, else the one that already configures it, else the base —
6342
+ * where a brand-new model lands and is re-filed from.
6343
+ */
6344
+ /**
6345
+ * Record which row a family's model belongs to, keeping the map bounded.
6346
+ *
6347
+ * Re-inserting keeps a key fresh, and the oldest half is dropped once the cap
6348
+ * is crossed, so a gateway whose catalogue is re-listed on every `/model`
6349
+ * does not accumulate a second copy of itself each time.
6350
+ */
6351
+ rememberFamilyOwner(base, model, row) {
6352
+ const key = `${base}\u0000${model}`;
6353
+ this.familyModelOwner.delete(key);
6354
+ this.familyModelOwner.set(key, row);
6355
+ if (this.familyModelOwner.size <= FAMILY_OWNER_CACHE_MAX)
6356
+ return;
6357
+ let remaining = Math.floor(FAMILY_OWNER_CACHE_MAX / 2);
6358
+ for (const oldest of this.familyModelOwner.keys()) {
6359
+ if (remaining <= 0)
6360
+ break;
6361
+ this.familyModelOwner.delete(oldest);
6362
+ remaining -= 1;
6363
+ }
6364
+ }
6365
+ familyOwnerRow(base, model, advertised) {
6366
+ const rows = this.providerFamilyRows(base);
6367
+ if (rows.length <= 1)
6368
+ return base;
6369
+ // What the gateway itself says comes first: a model no row configures and
6370
+ // no picker listed (`/model <id>` on a new id, a `--model` override) still
6371
+ // has a protocol, and filing it anywhere else makes the guard below refuse
6372
+ // a model the gateway is happy to serve.
6373
+ for (const protocol of advertised ?? []) {
6374
+ const row = rows.find(candidate => declaredProtocol(this.piAiProviderProfile(candidate)?.api) === protocol);
6375
+ if (row !== undefined)
6376
+ return row;
6377
+ }
6378
+ // The row that actually served the model in the last listing is evidence,
6379
+ // not a hint: it is the row the gateway said could send it. A row that
6380
+ // merely has the model configured used to win here, which sent an explicit
6381
+ // `/model <id>` back to the protocol the listing had just ruled out (the
6382
+ // completions row is still listed first for a stale responses entry).
6383
+ const learned = this.familyModelOwner.get(`${base}\u0000${model}`);
6384
+ if (learned !== undefined && rows.includes(learned))
6385
+ return learned;
6386
+ // No listing this run (a direct `/model <id>`, or an unreachable gateway):
6387
+ // keep the placement that is already configured. A model on two rows takes
6388
+ // the first, which is the family's own preference order — the same order
6389
+ // the wizard would file it under.
6390
+ for (const row of rows) {
6391
+ if (this.configuredModelsOf(row).some(entry => entry.id === model))
6392
+ return row;
6393
+ }
6394
+ return rows[0] ?? base;
6395
+ }
6396
+ /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */
6397
+ openCodeListingBaseURL(provider) {
6398
+ if (provider === 'opencode-go')
6399
+ return providerTemplates()['opencode-go'].defaultBaseUrl;
6400
+ if (provider === 'opencode')
6401
+ return OPENCODE_ZEN_BASE_URL;
6402
+ return undefined;
6403
+ }
6404
+ /**
6405
+ * Fetch the live model list for an OpenCode or third-party provider from its
6406
+ * OpenAI-compatible listing endpoint. The provider route is deliberately not
6407
+ * passed to discovery: pi-ai would answer a catalog route from its installed
6408
+ * registry, while the TUI wants the endpoint's current list.
6409
+ *
6410
+ * A row speaks exactly one protocol (`api` on the provider entry), so with a
6411
+ * gateway that publishes `supported_endpoints` the list is narrowed to the
6412
+ * models that row can actually serve — otherwise `/model` on
6413
+ * `command-code-messages` would offer, and then persist, a model that only
6414
+ * answers `/responses`.
6415
+ */
6416
+ async discoverEndpointModels(provider) {
6417
+ const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
6418
+ // Interactive callers get a shorter budget than the wizard's: `/model`
6419
+ // falling back to the configured list after a few seconds reads as "this
6420
+ // gateway is unreachable", while 15s reads as "the TUI hung".
6421
+ const { models, endpoints } = await this.discoverGatewayListing(provider, AbortSignal.timeout(6_000));
6422
+ return models.filter(model => gatewayServesModel(endpoints, model.id, protocol));
6423
+ }
6424
+ /**
6425
+ * One gateway's own model listing: every model the endpoint returns plus the
6426
+ * route table it publishes. The caller narrows it to a protocol; a gateway
6427
+ * spread over rows asks once and files the answer per row, so opening a
6428
+ * picker costs one listing instead of one per sibling.
6429
+ */
6430
+ async discoverGatewayListing(provider, signal) {
6431
+ const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6432
+ const profile = this.piAiProviderProfile(provider);
6433
+ const source = openCodeSourceFor(provider, llmPiAi);
6434
+ const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
6435
+ ? profile.baseURL.trim()
6436
+ : this.openCodeListingBaseURL(provider);
6437
+ if (baseURL === undefined)
6438
+ return { models: [], endpoints: undefined };
6439
+ const api = typeof profile?.api === 'string' && profile.api.trim() !== '' ? profile.api.trim() : undefined;
6440
+ const apiKeyEnv = typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== ''
6441
+ ? profile.apiKeyEnv.trim()
6442
+ : source?.apiKeyEnv;
6443
+ const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
6444
+ const llm = this.ctx.get('llm');
6445
+ if (llm === undefined)
6446
+ return { models: [], endpoints: undefined };
6447
+ const [discovered, endpoints] = await Promise.all([
6448
+ discoverProviderModels(llm, {
6449
+ baseURL,
6450
+ ...(api === undefined ? {} : { api }),
6451
+ ...(apiKey === undefined ? {} : { apiKey }),
6452
+ }, signal ?? AbortSignal.timeout(15_000)).catch(() => []),
6453
+ apiKey === undefined ? Promise.resolve(undefined) : fetchModelEndpoints(baseURL, apiKey, signal),
6454
+ ]);
6455
+ // The route table is the point of asking a gateway that publishes one: it is
6456
+ // what files each model on the protocol that can actually send it, and the
6457
+ // discovered list alone cannot. A gateway whose listing answered from the
6458
+ // installed catalog still gets its endpoints read here, and a gateway that
6459
+ // publishes no table keeps the discovered list as-is.
6460
+ const named = new Map(discovered.map(model => [model.id, model.name || model.id]));
6461
+ const models = endpoints === undefined
6462
+ ? discovered.map(model => ({ id: model.id, label: model.name || model.id }))
6463
+ : [...endpoints.keys()].map(id => ({ id, label: named.get(id) ?? id }));
6464
+ return { models, endpoints };
6465
+ }
6466
+ /**
6467
+ * The row of the same gateway that the gateway itself says serves `model`,
6468
+ * when that is not the row the caller is on. `undefined` when the gateway has
6469
+ * no opinion, when the current row already serves it, or when the model would
6470
+ * need a row that does not exist yet.
6471
+ */
6472
+ rowServingModel(provider, modelId, endpoints) {
6473
+ const protocol = declaredProtocol(this.piAiProviderProfile(provider)?.api);
6474
+ if (gatewayServesModel(endpoints, modelId, protocol))
6475
+ return undefined;
6476
+ const wanted = advertisedProtocols(endpoints, modelId)[0];
6477
+ if (wanted === undefined)
6478
+ return undefined;
6479
+ // The base id is a row of its own: use it when it already speaks `wanted`,
6480
+ // otherwise the sibling that does.
6481
+ const base = baseProviderIdOf(provider);
6482
+ const rows = declaredProtocol(this.piAiProviderProfile(base)?.api) === wanted
6483
+ ? [base, siblingProviderId(base, wanted)]
6484
+ : [siblingProviderId(base, wanted)];
6485
+ for (const row of rows) {
6486
+ if (row !== provider && this.piAiProviderProfile(row) !== undefined)
6487
+ return row;
6488
+ }
6489
+ return undefined;
5592
6490
  }
5593
6491
  /** Add one endpoint-listed model to the stored provider profile when needed. */
5594
6492
  async ensureProviderModelConfigured(provider, modelId) {
@@ -5612,6 +6510,39 @@ export class SshTui {
5612
6510
  }
5613
6511
  if (ids.has(modelId))
5614
6512
  return true;
6513
+ // Reached by a direct `/model <id>` too, where the picker's own filter never
6514
+ // ran: refuse to store a model this row's protocol cannot serve, and say
6515
+ // which sibling row can.
6516
+ const protocol = declaredProtocol(profile.api);
6517
+ const baseURL = typeof profile.baseURL === 'string' && profile.baseURL.trim() !== ''
6518
+ ? profile.baseURL.trim()
6519
+ : this.openCodeListingBaseURL(provider);
6520
+ if (protocol !== undefined && baseURL !== undefined) {
6521
+ const apiKeyEnv = typeof profile.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== '' ? profile.apiKeyEnv.trim() : undefined;
6522
+ const apiKey = apiKeyEnv === undefined ? undefined : await this.resolveCredential(apiKeyEnv);
6523
+ const endpoints = apiKey === undefined ? undefined : await fetchModelEndpoints(baseURL, apiKey);
6524
+ if (!gatewayServesModel(endpoints, modelId, protocol)) {
6525
+ const target = this.rowServingModel(provider, modelId, endpoints);
6526
+ // Report both names: the family is what `/provider` offers, the row is
6527
+ // what the settings file calls it. Sending the user to a hidden row was
6528
+ // a dead end — `/provider` lists families, so the hint names the family
6529
+ // and `/model` (which files the pick on the right row itself).
6530
+ const family = this.displayProviderId(provider);
6531
+ this.pushRow({
6532
+ kind: 'error',
6533
+ text: t('model.wrongRoute', {
6534
+ model: modelId,
6535
+ provider: family === provider ? provider : `${family}(${provider})`,
6536
+ endpoint: GATEWAY_PROTOCOL_ENDPOINT[protocol],
6537
+ hint: target === undefined
6538
+ ? t('model.wrongRouteSetup')
6539
+ : t('model.wrongRouteTarget', { provider: family }),
6540
+ }),
6541
+ });
6542
+ this.markDirty();
6543
+ return false;
6544
+ }
6545
+ }
5615
6546
  const modelEntry = { id: modelId };
5616
6547
  // A model added here skips the wizard, so size it from the installed
5617
6548
  // catalog the same way `/setup` does; the route default covers a miss.
@@ -5621,14 +6552,18 @@ export class SshTui {
5621
6552
  if (contextWindow !== undefined)
5622
6553
  modelEntry.contextWindow = contextWindow;
5623
6554
  }
5624
- 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);
5625
6560
  if (reasoningEfforts !== undefined)
5626
6561
  modelEntry.reasoningEfforts = reasoningEfforts;
5627
6562
  try {
5628
6563
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
5629
6564
  { op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
5630
6565
  ]);
5631
- this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider }) });
6566
+ this.pushRow({ kind: 'system', text: t('model.added', { model: modelId, provider: this.displayProviderId(provider) }) });
5632
6567
  this.markDirty();
5633
6568
  return true;
5634
6569
  }
@@ -5673,16 +6608,22 @@ export class SshTui {
5673
6608
  /** Live adapter routes the TUI can switch to, plus the current selection. */
5674
6609
  listSelectableProviders() {
5675
6610
  const llm = this.ctx.get('llm');
5676
- const current = this.currentProviderId();
6611
+ const current = this.displayProviderId(this.currentProviderId());
5677
6612
  const seen = new Set();
5678
6613
  const out = [];
5679
6614
  const add = (id, name) => {
5680
- if (id === '' || seen.has(id))
6615
+ if (id === '')
5681
6616
  return;
5682
- seen.add(id);
5683
- const kind = describeProviderRoute(id);
5684
- const display = name !== undefined && name !== '' && name !== id ? name : kind.short;
5685
- 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}` });
5686
6627
  };
5687
6628
  add(current);
5688
6629
  for (const info of llm?.listProviders() ?? [])
@@ -5700,6 +6641,9 @@ export class SshTui {
5700
6641
  { id: 'grok-4.3', label: 'Grok 4.3' },
5701
6642
  ];
5702
6643
  async loadModelOptions(provider) {
6644
+ const family = this.providerFamilyRows(provider);
6645
+ if (family.length > 1)
6646
+ return this.loadFamilyModelOptions(family[0] ?? provider, family);
5703
6647
  const llm = this.ctx.get('llm');
5704
6648
  let options = [];
5705
6649
  let source = t('model.configured');
@@ -5754,16 +6698,86 @@ export class SshTui {
5754
6698
  }
5755
6699
  return { options, source };
5756
6700
  }
6701
+ /**
6702
+ * The model list of a gateway spread over protocol rows.
6703
+ *
6704
+ * Every row is asked what it serves and the answers are merged under the
6705
+ * family's base id, so the user picks from one list. Each model remembers the
6706
+ * row that offered it, which is what routes the pick back to the protocol
6707
+ * that can actually send it — the split stays invisible end to end.
6708
+ */
6709
+ async loadFamilyModelOptions(base, family) {
6710
+ const llm = this.ctx.get('llm');
6711
+ const options = [];
6712
+ const seen = new Set();
6713
+ const add = (row, id, label) => {
6714
+ if (id === '' || seen.has(id))
6715
+ return;
6716
+ seen.add(id);
6717
+ this.rememberFamilyOwner(base, id, row);
6718
+ options.push({ id, label: label !== undefined && label !== '' ? label : id });
6719
+ };
6720
+ const previousStatus = this.status;
6721
+ this.status = t('model.fetching', { provider: base });
6722
+ this.markDirty();
6723
+ try {
6724
+ // The gateway's own listing is what decides which row serves what, so it
6725
+ // is answered first and `add` keeps the first row that claims an id. Doing
6726
+ // the configured rows first would let a leftover entry on the base row
6727
+ // shadow the sibling the gateway actually files the model under — the
6728
+ // picker would then show a model under a protocol that cannot send it,
6729
+ // and `/model` would write it back onto that row.
6730
+ const listings = await this.discoverGatewayListing(base, AbortSignal.timeout(6_000)).catch(() => undefined);
6731
+ if (listings !== undefined) {
6732
+ for (const row of family) {
6733
+ const protocol = declaredProtocol(this.piAiProviderProfile(row)?.api);
6734
+ for (const model of listings.models) {
6735
+ if (gatewayServesModel(listings.endpoints, model.id, protocol))
6736
+ add(row, model.id, model.label);
6737
+ }
6738
+ }
6739
+ }
6740
+ // Whatever the endpoint did not answer: the rows that already configure a
6741
+ // model keep it (so a gateway that is down still offers its own list).
6742
+ for (const row of family) {
6743
+ for (const model of this.configuredModelsOf(row))
6744
+ add(row, model.id, model.label);
6745
+ }
6746
+ // Models the installed catalog knows but neither source listed.
6747
+ for (const row of family) {
6748
+ try {
6749
+ for (const model of (await llm?.listModels(row)) ?? [])
6750
+ add(row, model.id, model.name || model.id);
6751
+ }
6752
+ catch {
6753
+ // The endpoint listing stands alone when the catalog cannot be read.
6754
+ }
6755
+ }
6756
+ }
6757
+ finally {
6758
+ this.status = previousStatus;
6759
+ this.markDirty();
6760
+ }
6761
+ if (options.length === 0) {
6762
+ const remembered = this.rememberedRoute(base)?.model;
6763
+ if (remembered !== undefined)
6764
+ options.push({ id: remembered, label: remembered });
6765
+ }
6766
+ return { options, source: options.length > 0 ? t('model.live') : t('model.configured') };
6767
+ }
5757
6768
  /** /model: models and effort for the current provider only. */
5758
6769
  async runModelCommand() {
5759
6770
  const provider = this.currentProviderId();
6771
+ // The list is the whole family's; the question names the gateway, not the
6772
+ // protocol row the current request happens to use.
6773
+ const display = this.displayProviderId(provider);
5760
6774
  const current = this.selectionRef?.current;
5761
6775
  const loaded = await this.loadModelOptions(provider);
5762
6776
  let modelOptions = loaded.options;
5763
6777
  if (current?.model !== undefined && !modelOptions.some(option => option.id === current.model)) {
5764
6778
  modelOptions = [{ id: current.model, label: current.model }, ...modelOptions];
5765
6779
  }
5766
- const selected = await this.pickModelOption(modelOptions, provider, loaded.source, current?.model);
6780
+ const selected = await this.pickModelOption(modelOptions, display, loaded.source, current?.model);
5767
6781
  if (selected === undefined)
5768
6782
  return;
5769
6783
  await this.applyModelSelection(provider, selected.id, modelOptions.map(option => option.id));
@@ -5771,7 +6785,10 @@ export class SshTui {
5771
6785
  /** /provider: pick a provider, then its model (remembered route pre-filled). */
5772
6786
  async runProviderCommand() {
5773
6787
  const providers = this.listSelectableProviders();
5774
- 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());
5775
6792
  if (providers.length === 0) {
5776
6793
  this.pushRow({ kind: 'error', text: t('provider.none') });
5777
6794
  this.markDirty();
@@ -5810,7 +6827,46 @@ export class SshTui {
5810
6827
  : {};
5811
6828
  return rememberedRouteFor(memory, provider);
5812
6829
  }
6830
+ /** The route this session is running right now, record-shaped. */
6831
+ sessionRouteRecord() {
6832
+ const current = this.selectionRef?.current;
6833
+ const subagent = this.subagentSelection.current;
6834
+ // Shaped by the launcher's own builder, so what the TUI reports and what the
6835
+ // launcher writes cannot drift apart.
6836
+ return sessionRouteInput({
6837
+ provider: current?.provider ?? this.currentProviderId(),
6838
+ model: current?.model ?? this.agent.options.model ?? '',
6839
+ ...(current?.reasoningEffort === undefined ? {} : { reasoningEffort: String(current.reasoningEffort) }),
6840
+ subagent: {
6841
+ ...(subagent.provider === undefined ? {} : { provider: subagent.provider }),
6842
+ model: subagent.model,
6843
+ ...(subagent.reasoningEffort === undefined ? {} : { reasoningEffort: String(subagent.reasoningEffort) }),
6844
+ },
6845
+ });
6846
+ }
6847
+ /**
6848
+ * Tell the launcher which route this session has settled on, so resuming it
6849
+ * comes back on the same supplier — the subagent route included, which is what
6850
+ * makes a resumed cross-provider conversation reproducible.
6851
+ *
6852
+ * Reported when a route settles (`/model`, `/submodel`, `/setup`) and at the
6853
+ * start of every turn, which catches a preset or a settings edit moving the
6854
+ * route behind the TUI's back. The launcher owns the file; the TUI only says
6855
+ * what changed, and says nothing when nothing did.
6856
+ */
6857
+ noteSessionRoute() {
6858
+ const route = this.sessionRouteRecord();
6859
+ if (route === undefined)
6860
+ return;
6861
+ if (sameSessionRoute(this.lastSessionRoute, route))
6862
+ return;
6863
+ this.lastSessionRoute = { ...route, updatedAt: Date.now() };
6864
+ this.onRouteSettled?.(route);
6865
+ }
5813
6866
  async rememberRoute(selection) {
6867
+ // The session's record is kept even when the settings service is missing:
6868
+ // coming back to this conversation on the same supplier is the point of it.
6869
+ this.noteSessionRoute();
5814
6870
  const settings = this.ctx.get('settings');
5815
6871
  if (settings === undefined)
5816
6872
  return;
@@ -5827,19 +6883,25 @@ export class SshTui {
5827
6883
  ]);
5828
6884
  }
5829
6885
  async applyModelSelection(provider, modelId, listed = [], preferredEffort) {
5830
- 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)))
5831
6893
  return;
5832
6894
  const llm = this.ctx.get('llm');
5833
6895
  const current = this.selectionRef?.current;
5834
6896
  let effortOptions = [];
5835
6897
  try {
5836
- const info = await llm?.resolveModelInfo(provider, modelId);
6898
+ const info = await llm?.resolveModelInfo(target, modelId);
5837
6899
  effortOptions = (info?.reasoning?.efforts ?? []).map(effort => ({ id: String(effort.id), label: effort.name }));
5838
6900
  }
5839
6901
  catch {
5840
6902
  effortOptions = [];
5841
6903
  }
5842
- if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
6904
+ if (effortOptions.length === 0 && providerUsesLocalOAuth(target)) {
5843
6905
  effortOptions = localOAuthEffortChoices(modelId);
5844
6906
  }
5845
6907
  const isUndeclared = effortOptions.length === 0;
@@ -5856,8 +6918,8 @@ export class SshTui {
5856
6918
  desc: undefined,
5857
6919
  })),
5858
6920
  ];
5859
- const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
5860
- const currentEffort = current?.provider === provider
6921
+ const rememberedEffort = this.rememberedRoute(display)?.reasoningEffort ?? preferredEffort ?? '';
6922
+ const currentEffort = current?.provider === target
5861
6923
  ? String(current?.reasoningEffort ?? '')
5862
6924
  : rememberedEffort;
5863
6925
  const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
@@ -5873,9 +6935,9 @@ export class SshTui {
5873
6935
  }, 0, 1, currentIndex);
5874
6936
  const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
5875
6937
  if (isUndeclared && effort !== undefined)
5876
- await this.declareReasoningEffort(provider, modelId, effort);
6938
+ await this.declareReasoningEffort(target, modelId, effort);
5877
6939
  const next = {
5878
- provider,
6940
+ provider: target,
5879
6941
  model: modelId,
5880
6942
  ...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
5881
6943
  };
@@ -5883,23 +6945,23 @@ export class SshTui {
5883
6945
  this.selectionRef.current = next;
5884
6946
  this.onSelectionChanged?.(next);
5885
6947
  await this.persistDefaultSelection(next);
5886
- await this.rememberRoute(next);
5887
- const kind = describeProviderRoute(provider);
6948
+ await this.rememberRoute({ ...next, provider: display });
6949
+ const kind = describeProviderRoute(display);
5888
6950
  const effortText = effort ?? t('effort.defaultShort');
5889
6951
  const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
5890
6952
  this.pushRow({
5891
6953
  kind: 'system',
5892
- text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
6954
+ text: t('effort.switchedModel', { kind: kind.kind, provider: display, model: modelId, effort: effortText, note }),
5893
6955
  });
5894
6956
  const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
5895
6957
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
5896
- if (previousProvider !== provider) {
5897
- await this.syncSubagentToProvider(provider, listedIds, true);
5898
- this.clearQuotaForProvider(provider);
6958
+ if (this.displayProviderId(previousProvider) !== display) {
6959
+ await this.syncSubagentToProvider(display, listedIds, true);
6960
+ this.clearQuotaForProvider(target);
5899
6961
  void this.refreshQuota({ reason: 'command', announce: false }).catch(() => { });
5900
6962
  }
5901
6963
  else {
5902
- await this.syncSubagentToProvider(provider, listedIds);
6964
+ await this.syncSubagentToProvider(display, listedIds);
5903
6965
  }
5904
6966
  this.markDirty();
5905
6967
  }
@@ -5955,11 +7017,23 @@ export class SshTui {
5955
7017
  * When the parent provider changes (OAuth or API key), keep the subagent
5956
7018
  * on a same-family model. An explicit leftover DeepSeek flash id after
5957
7019
  * switching to xAI is treated as stale.
7020
+ *
7021
+ * A `/submodel` pin is the user's explicit route, so a parent switch asks
7022
+ * before moving the children: dropping the pin silently spends on a route
7023
+ * they pinned away from, and keeping it silently leaves the children on the
7024
+ * old provider.
5958
7025
  */
5959
7026
  async syncSubagentToProvider(provider, listed = [], force = false) {
7027
+ // Children follow the family the user sees, not the protocol row the parent
7028
+ // request happens to use.
7029
+ provider = this.displayProviderId(provider);
5960
7030
  const current = this.subagentSelection.current;
5961
- if (!force && current.provider !== undefined && current.provider !== provider)
5962
- return;
7031
+ if (current.provider !== undefined) {
7032
+ if (!force)
7033
+ return;
7034
+ if (await this.confirmKeepSubagentPin(provider, current))
7035
+ return;
7036
+ }
5963
7037
  if (!force && subagentModelMatchesProvider(provider, current.model, listed))
5964
7038
  return;
5965
7039
  let catalog = [...listed];
@@ -5985,6 +7059,31 @@ export class SshTui {
5985
7059
  text: t('sub.followed', { provider, model: nextModel, sessionOnly: persisted ? '' : t('sub.sessionOnly') }),
5986
7060
  });
5987
7061
  }
7062
+ /**
7063
+ * Ask whether a `/submodel` pin survives a parent provider change.
7064
+ *
7065
+ * Returns true when the pin should be kept. Cancelling the dialog keeps it
7066
+ * too: the pin is the user's explicit choice, so only an explicit answer
7067
+ * moves the children onto the new parent route.
7068
+ */
7069
+ async confirmKeepSubagentPin(provider, current) {
7070
+ const pinned = this.displayProviderId(current.provider ?? '');
7071
+ const keepLabel = t('sub.followKeepLabel', { provider: pinned });
7072
+ const followLabel = t('sub.followSwitchLabel', { provider });
7073
+ const answer = await this.askQuestion({
7074
+ id: 'submodel-follow-pick',
7075
+ question: t('sub.followAsk', { pinned, provider, model: current.model }),
7076
+ options: [
7077
+ { label: keepLabel, description: t('sub.followKeepHint') },
7078
+ { label: followLabel, description: t('sub.followSwitchHint', { provider }) },
7079
+ ],
7080
+ }, 0, 1, 0);
7081
+ if (answer.selected[0] === followLabel)
7082
+ return false;
7083
+ this.pushRow({ kind: 'system', text: t('sub.pinKept', { pinned, parent: provider }) });
7084
+ this.markDirty();
7085
+ return true;
7086
+ }
5988
7087
  clearQuotaForProvider(provider) {
5989
7088
  const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
5990
7089
  const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
@@ -6002,6 +7101,13 @@ export class SshTui {
6002
7101
  /** Persist one subagent selection and publish it to the live request waterfall. */
6003
7102
  async saveSubagentSelection(next) {
6004
7103
  this.subagentSelection.current = next;
7104
+ // An explicit change makes the settings the source again: the next time
7105
+ // this session is resumed, the record — not a stale in-memory override —
7106
+ // decides, and the settings watcher may update the ref again.
7107
+ this.subagentSelection.source = 'settings';
7108
+ // The session record keeps the subagent route too, so a resumed
7109
+ // cross-provider conversation comes back on the same children.
7110
+ this.noteSessionRoute();
6005
7111
  const settings = this.ctx.get('settings');
6006
7112
  if (settings === undefined) {
6007
7113
  this.pushRow({ kind: 'error', text: t('sub.settingsMissing') });
@@ -6013,6 +7119,9 @@ export class SshTui {
6013
7119
  }
6014
7120
  /** Resolve the picker model list for one provider (endpoint first, then catalog). */
6015
7121
  async subagentModelOptions(provider) {
7122
+ const family = this.providerFamilyRows(provider);
7123
+ if (family.length > 1)
7124
+ return this.loadFamilyModelOptions(family[0] ?? provider, family);
6016
7125
  const llm = this.ctx.get('llm');
6017
7126
  let options = [];
6018
7127
  let source = t('model.configured');
@@ -6058,11 +7167,10 @@ export class SshTui {
6058
7167
  }
6059
7168
  /** /submodel: pick (or set) the model subagent children use. */
6060
7169
  async runSubmodelCommand(arg) {
6061
- const provider = this.effectiveSubagentProvider();
6062
7170
  const current = this.subagentSelection.current;
6063
7171
  const direct = arg.trim();
6064
7172
  if (direct.toLowerCase() === 'reset' || direct === '跟随' || direct === '默认') {
6065
- const parentProvider = this.currentProviderId();
7173
+ const parentProvider = this.displayProviderId(this.currentProviderId());
6066
7174
  const nextModel = defaultSubagentModelForProvider(parentProvider, [], this.selectionRef?.current?.model ?? this.agent.options.model);
6067
7175
  const persisted = await this.saveSubagentSelection({
6068
7176
  model: nextModel,
@@ -6075,8 +7183,42 @@ export class SshTui {
6075
7183
  this.markDirty();
6076
7184
  return;
6077
7185
  }
7186
+ const slash = direct.indexOf('/');
7187
+ if (slash > 0) {
7188
+ const providerId = direct.slice(0, slash).trim();
7189
+ const modelId = direct.slice(slash + 1).trim();
7190
+ if (providerId !== '' && modelId !== '') {
7191
+ await this.commitSubagentRoute(providerId, modelId, { pinProvider: true });
7192
+ return;
7193
+ }
7194
+ }
7195
+ let provider = this.displayProviderId(this.effectiveSubagentProvider());
6078
7196
  let selectedId = direct;
6079
7197
  if (selectedId === '') {
7198
+ const parentProvider = this.displayProviderId(this.currentProviderId());
7199
+ const providers = this.listSelectableProviders();
7200
+ const currentIndex = Math.max(0, providers.findIndex(option => option.id === provider));
7201
+ const pinnedProvider = this.displayProviderId(current.provider ?? '');
7202
+ const pickedAnswer = await this.askQuestion({
7203
+ id: 'submodel-provider-pick',
7204
+ question: t('sub.pickProvider'),
7205
+ options: providers.map(option => ({
7206
+ label: option.label,
7207
+ // The description says what picking the row does. Picking the parent's
7208
+ // provider follows it — that is the one explicit way to unpin without
7209
+ // `/submodel reset` — so the parent row must not read "currently
7210
+ // pinned" just because the pin happens to name it.
7211
+ description: option.id === parentProvider
7212
+ ? t('sub.providerFollowsParent')
7213
+ : option.id === pinnedProvider
7214
+ ? t('sub.providerPinned')
7215
+ : describeProviderRoute(option.id).kind,
7216
+ })),
7217
+ }, 0, 1, currentIndex);
7218
+ const pickedProvider = providers.find(option => option.label === pickedAnswer.selected[0])?.id;
7219
+ if (pickedProvider === undefined)
7220
+ return;
7221
+ provider = pickedProvider;
6080
7222
  const { options, source } = await this.subagentModelOptions(provider);
6081
7223
  if (options.length === 0) {
6082
7224
  options.push({ id: current.model, label: current.model });
@@ -6086,14 +7228,30 @@ export class SshTui {
6086
7228
  return;
6087
7229
  selectedId = selected.id;
6088
7230
  }
6089
- 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)))
6090
7242
  return;
6091
- 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);
6092
7250
  this.pushRow({
6093
7251
  kind: 'system',
6094
- text: (current.provider === undefined
6095
- ? t('sub.modelFollow', { model: selectedId, provider })
6096
- : 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 }))
6097
7255
  + (persisted ? '' : t('sub.sessionOnly')),
6098
7256
  });
6099
7257
  this.markDirty();
@@ -6101,6 +7259,7 @@ export class SshTui {
6101
7259
  /** /effort: pick or set the reasoning effort for the current model. */
6102
7260
  async runEffortCommand(arg) {
6103
7261
  const provider = this.currentProviderId();
7262
+ const display = this.displayProviderId(provider);
6104
7263
  const current = this.selectionRef?.current;
6105
7264
  if (current === undefined || current.model === undefined) {
6106
7265
  this.pushRow({ kind: 'error', text: t('effort.noModel') });
@@ -6157,8 +7316,8 @@ export class SshTui {
6157
7316
  const answer = await this.askQuestion({
6158
7317
  id: 'effort-pick',
6159
7318
  question: isUndeclared
6160
- ? t('effort.pickCurrentUndeclared', { provider, model: modelId })
6161
- : t('effort.pickCurrent', { provider, model: modelId }),
7319
+ ? t('effort.pickCurrentUndeclared', { provider: display, model: modelId })
7320
+ : t('effort.pickCurrent', { provider: display, model: modelId }),
6162
7321
  options: choices.map(c => ({
6163
7322
  label: c.label,
6164
7323
  description: c.id === currentEffort ? t('disconnect.current') : c.desc,
@@ -6932,6 +8091,9 @@ export class SshTui {
6932
8091
  else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
6933
8092
  return;
6934
8093
  }
8094
+ else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(-1)) {
8095
+ return;
8096
+ }
6935
8097
  else if (this.suggestionsVisible()) {
6936
8098
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
6937
8099
  this.markDirty();
@@ -6953,6 +8115,9 @@ export class SshTui {
6953
8115
  else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
6954
8116
  return;
6955
8117
  }
8118
+ else if (this.dialog?.kind === 'onboarding' && this.moveOnboardingModelCursor(1)) {
8119
+ return;
8120
+ }
6956
8121
  else if (this.suggestionsVisible()) {
6957
8122
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
6958
8123
  this.markDirty();
@@ -7408,8 +8573,8 @@ export class SshTui {
7408
8573
  const templates = providerTemplates();
7409
8574
  const templateEntries = [
7410
8575
  { key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
7411
- { key: 'template:opencode-go', label: templates['opencode-go'].label, detail: 'opencode.ai/zen/go · Responses' },
7412
- { 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') },
7413
8578
  { key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
7414
8579
  { key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
7415
8580
  { key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
@@ -7456,6 +8621,108 @@ export class SshTui {
7456
8621
  }
7457
8622
  return sized;
7458
8623
  }
8624
+ /**
8625
+ * Leave the models step, after one best-effort trip to the gateway for what
8626
+ * the picks are still missing: the context window / output cap of every model
8627
+ * the listing has not described yet, and — for a gateway that spreads over
8628
+ * several protocols — the endpoints each picked model answers.
8629
+ *
8630
+ * `Ctrl+F` stays as it was, but it is no longer the only way to learn these:
8631
+ * a model typed by hand would otherwise be saved with no capacity at all, and
8632
+ * with no endpoint data the split had nothing to file it by.
8633
+ */
8634
+ async finishModelsStep(state) {
8635
+ if (this.onboarding !== state || !isModelsStep(state.step))
8636
+ return;
8637
+ if (this.finishingModels.has(state))
8638
+ return;
8639
+ this.finishingModels.add(state);
8640
+ try {
8641
+ const previousStatus = this.status;
8642
+ if (this.onboardingWantsEndpoint(state)) {
8643
+ const fetching = t('onboard.fetchingModels');
8644
+ this.status = fetching;
8645
+ this.markDirty();
8646
+ await this.enrichOnboardingModels(state);
8647
+ // Only take back the line this step put up: a turn that started while
8648
+ // the listing was in flight owns the status now, and restoring the
8649
+ // pre-fetch value would erase "turn 7 running" behind its back.
8650
+ if (this.status === fetching)
8651
+ this.status = previousStatus;
8652
+ }
8653
+ // The wizard may have been cancelled or reset while the fetch was in flight.
8654
+ if (this.onboarding !== state || !isModelsStep(state.step))
8655
+ return;
8656
+ // Size the picks before the confirm step so the route default the wizard
8657
+ // persists is derived, not guessed: the listing first, the installed
8658
+ // catalog second. Only when some pick stayed unsized does the route
8659
+ // default matter, and only then is a step shown for it.
8660
+ const sized = this.sizeOnboardingModels(state);
8661
+ const unsized = state.models.filter(id => !sized.has(id));
8662
+ if (unsized.length === 0) {
8663
+ state.step = 'confirm';
8664
+ }
8665
+ else {
8666
+ state.routeContextWindow = suggestedRouteContextWindow(sized.values())
8667
+ ?? HARNESS_DEFAULT_CONTEXT_WINDOW;
8668
+ state.step = 'context';
8669
+ }
8670
+ this.input = '';
8671
+ this.cursor = 0;
8672
+ this.markDirty();
8673
+ }
8674
+ finally {
8675
+ this.finishingModels.delete(state);
8676
+ }
8677
+ }
8678
+ /** Whether leaving the models step has anything left to ask the gateway for. */
8679
+ onboardingWantsEndpoint(state) {
8680
+ if (onboardTemplate(state).protocols !== undefined && state.modelEndpoints === undefined)
8681
+ return true;
8682
+ return state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
8683
+ }
8684
+ /** Fill the picks' capacities and endpoints from the gateway; never fatal. */
8685
+ async enrichOnboardingModels(state) {
8686
+ const template = onboardTemplate(state);
8687
+ const baseURL = state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl;
8688
+ if (baseURL === '')
8689
+ return;
8690
+ const wantsEndpoints = template.protocols !== undefined && state.modelEndpoints === undefined;
8691
+ const wantsCapacity = state.models.some(id => state.modelCapacity?.get(id)?.contextWindow === undefined);
8692
+ const llm = this.ctx.get('llm');
8693
+ if (wantsCapacity && llm !== undefined) {
8694
+ try {
8695
+ const discovered = await discoverProviderModels(llm, {
8696
+ baseURL,
8697
+ ...(template.api === undefined ? {} : { api: template.api }),
8698
+ ...(state.key === '' ? {} : { apiKey: state.key }),
8699
+ }, AbortSignal.timeout(8_000));
8700
+ state.modelCapacity ??= new Map();
8701
+ for (const model of discovered) {
8702
+ if (!state.models.includes(model.id))
8703
+ continue;
8704
+ const capacity = { ...(state.modelCapacity.get(model.id) ?? {}) };
8705
+ if (capacity.contextWindow === undefined && model.contextWindow !== undefined) {
8706
+ capacity.contextWindow = model.contextWindow;
8707
+ }
8708
+ if (capacity.maxTokens === undefined && model.maxTokens !== undefined) {
8709
+ capacity.maxTokens = model.maxTokens;
8710
+ }
8711
+ if (Object.keys(capacity).length > 0)
8712
+ state.modelCapacity.set(model.id, capacity);
8713
+ }
8714
+ }
8715
+ catch {
8716
+ // The listing is an enrichment, not a gate: the installed catalog and
8717
+ // the route default still cover a gateway that cannot be listed.
8718
+ }
8719
+ }
8720
+ if (wantsEndpoints) {
8721
+ const endpoints = await fetchModelEndpoints(baseURL, state.key);
8722
+ if (endpoints !== undefined)
8723
+ state.modelEndpoints = endpoints;
8724
+ }
8725
+ }
7459
8726
  handleOnboardingChar(text) {
7460
8727
  const state = this.onboarding;
7461
8728
  if (state === undefined)
@@ -7515,7 +8782,77 @@ export class SshTui {
7515
8782
  case 'base-url':
7516
8783
  case 'key':
7517
8784
  case 'models':
8785
+ case 'models-pick':
8786
+ case 'model-default':
7518
8787
  case 'context': {
8788
+ if (state.step === 'models-pick') {
8789
+ // The step is already being left (the gateway listing is in flight):
8790
+ // the pick is decided, so a stray key must not reopen it.
8791
+ if (this.finishingModels.has(state))
8792
+ return;
8793
+ const total = state.modelCandidates?.length ?? 0;
8794
+ if (total === 0) {
8795
+ state.step = 'models';
8796
+ this.markDirty();
8797
+ return;
8798
+ }
8799
+ const checked = state.modelChecked ?? (state.modelChecked = new Set());
8800
+ if (text === '\r' || text === '\n') {
8801
+ const picked = this.checkedOnboardingModels(state);
8802
+ if (picked.length === 0) {
8803
+ this.pushRow({ kind: 'error', text: t('onboard.needModel') });
8804
+ this.markDirty();
8805
+ return;
8806
+ }
8807
+ state.models = picked;
8808
+ state.defaultModel = picked[0];
8809
+ if (picked.length > 1) {
8810
+ state.step = 'model-default';
8811
+ state.modelCursor = 0;
8812
+ this.markDirty();
8813
+ return;
8814
+ }
8815
+ this.advanceOnboarding();
8816
+ return;
8817
+ }
8818
+ if (text === ' ' || text === ' ') {
8819
+ const index = state.modelCursor ?? 0;
8820
+ if (checked.has(index))
8821
+ checked.delete(index);
8822
+ else
8823
+ checked.add(index);
8824
+ this.markDirty();
8825
+ return;
8826
+ }
8827
+ const hotkey = questionOptionIndex(text, total);
8828
+ if (hotkey !== undefined) {
8829
+ state.modelCursor = hotkey;
8830
+ if (checked.has(hotkey))
8831
+ checked.delete(hotkey);
8832
+ else
8833
+ checked.add(hotkey);
8834
+ this.markDirty();
8835
+ }
8836
+ return;
8837
+ }
8838
+ if (state.step === 'model-default') {
8839
+ if (this.finishingModels.has(state))
8840
+ return;
8841
+ const picked = this.checkedOnboardingModels(state);
8842
+ if (text === '\r' || text === '\n') {
8843
+ const chosen = picked[Math.min(state.modelCursor ?? 0, Math.max(0, picked.length - 1))];
8844
+ if (chosen !== undefined)
8845
+ state.defaultModel = chosen;
8846
+ this.advanceOnboarding();
8847
+ return;
8848
+ }
8849
+ const hotkey = questionOptionIndex(text, picked.length);
8850
+ if (hotkey !== undefined) {
8851
+ state.modelCursor = hotkey;
8852
+ this.markDirty();
8853
+ }
8854
+ return;
8855
+ }
7519
8856
  if (state.step === 'models' && text === '\x06') {
7520
8857
  void this.fetchOnboardingModels();
7521
8858
  return;
@@ -7541,19 +8878,26 @@ export class SshTui {
7541
8878
  state.key = value;
7542
8879
  }
7543
8880
  else if (state.step === 'models') {
7544
- const template = onboardTemplate(state);
7545
- // Ctrl+F has already filled state.models; Enter accepts that
7546
- // listing instead of discarding it for the template's placeholder
7547
- // ids, which a generic gateway does not serve.
7548
- const parsed = value === ''
7549
- ? (state.models.length > 0 ? state.models : template.defaultModels)
7550
- : 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);
7551
8894
  if (parsed.length === 0) {
7552
8895
  this.pushRow({ kind: 'error', text: t('onboard.needModel') });
7553
8896
  this.markDirty();
7554
8897
  return;
7555
8898
  }
7556
8899
  state.models = parsed;
8900
+ state.defaultModel = parsed[0];
7557
8901
  }
7558
8902
  else if (state.step === 'context') {
7559
8903
  // Enter keeps the pre-filled value; a typed number overrides it.
@@ -7600,6 +8944,12 @@ export class SshTui {
7600
8944
  state.baseUrl = '';
7601
8945
  state.key = '';
7602
8946
  state.models = [];
8947
+ // A restart must not inherit the previous run's picker: a stale
8948
+ // `modelChecked` would pre-tick models from the provider being left.
8949
+ state.defaultModel = undefined;
8950
+ state.modelCandidates = [];
8951
+ state.modelChecked = new Set();
8952
+ state.modelCursor = 0;
7603
8953
  state.modelCapacity = new Map();
7604
8954
  this.input = '';
7605
8955
  this.cursor = 0;
@@ -7624,21 +8974,11 @@ export class SshTui {
7624
8974
  else if (state.step === 'key') {
7625
8975
  state.step = 'models';
7626
8976
  }
7627
- else if (state.step === 'models') {
7628
- // Size the picks before the confirm step so the route default the wizard
7629
- // persists is derived, not guessed: the listing first, the installed
7630
- // catalog second. Only when some pick stayed unsized does the route
7631
- // default matter, and only then is a step shown for it.
7632
- const sized = this.sizeOnboardingModels(state);
7633
- const unsized = state.models.filter(id => !sized.has(id));
7634
- if (unsized.length === 0) {
7635
- state.step = 'confirm';
7636
- }
7637
- else {
7638
- state.routeContextWindow = suggestedRouteContextWindow(sized.values())
7639
- ?? HARNESS_DEFAULT_CONTEXT_WINDOW;
7640
- state.step = 'context';
7641
- }
8977
+ else if (isModelsStep(state.step)) {
8978
+ // Leaving the models step is asynchronous: the gateway is asked once more
8979
+ // for whatever the picks are still missing (see finishModelsStep).
8980
+ void this.finishModelsStep(state);
8981
+ return;
7642
8982
  }
7643
8983
  else if (state.step === 'context') {
7644
8984
  state.step = 'confirm';
@@ -7647,6 +8987,92 @@ export class SshTui {
7647
8987
  this.cursor = 0;
7648
8988
  this.markDirty();
7649
8989
  }
8990
+ /**
8991
+ * Open the models picker: every model the listing offered — or the template's
8992
+ * pinned ones when nothing was listed — with the template's models checked.
8993
+ *
8994
+ * Enter used to accept the whole listing, which quietly wrote every model of
8995
+ * the gateway into settings and made the session's model whichever id came
8996
+ * first. The picker makes both the set and the session's model explicit; a
8997
+ * typed list still skips it.
8998
+ */
8999
+ openOnboardingModelPicker(state) {
9000
+ const template = onboardTemplate(state);
9001
+ const candidates = [...new Set((state.models.length > 0 ? state.models : template.defaultModels).filter(id => id !== ''))];
9002
+ if (candidates.length === 0) {
9003
+ this.pushRow({ kind: 'error', text: t('onboard.needModel') });
9004
+ this.markDirty();
9005
+ return;
9006
+ }
9007
+ const pinned = new Set(template.defaultModels);
9008
+ const checked = new Set();
9009
+ candidates.forEach((id, index) => {
9010
+ if (pinned.has(id))
9011
+ checked.add(index);
9012
+ });
9013
+ if (checked.size === 0)
9014
+ checked.add(0);
9015
+ state.modelCandidates = candidates;
9016
+ state.modelChecked = checked;
9017
+ state.modelCursor = Math.min(...checked);
9018
+ state.step = 'models-pick';
9019
+ this.input = '';
9020
+ this.cursor = 0;
9021
+ this.markDirty();
9022
+ }
9023
+ /** The checked models of the wizard's picker, in listing order. */
9024
+ checkedOnboardingModels(state) {
9025
+ const candidates = state.modelCandidates ?? [];
9026
+ return [...(state.modelChecked ?? new Set())]
9027
+ .sort((left, right) => left - right)
9028
+ .map(index => candidates[index])
9029
+ .filter((id) => id !== undefined);
9030
+ }
9031
+ /** Move the highlight in either picker step. */
9032
+ moveOnboardingModelCursor(delta) {
9033
+ const state = this.onboarding;
9034
+ if (state === undefined)
9035
+ return false;
9036
+ if (state.step === 'models-pick') {
9037
+ const total = state.modelCandidates?.length ?? 0;
9038
+ if (total === 0)
9039
+ return false;
9040
+ state.modelCursor = Math.max(0, Math.min(total - 1, (state.modelCursor ?? 0) + delta));
9041
+ this.markDirty();
9042
+ return true;
9043
+ }
9044
+ if (state.step === 'model-default') {
9045
+ const picked = this.checkedOnboardingModels(state);
9046
+ if (picked.length === 0)
9047
+ return false;
9048
+ state.modelCursor = Math.max(0, Math.min(picked.length - 1, (state.modelCursor ?? 0) + delta));
9049
+ this.markDirty();
9050
+ return true;
9051
+ }
9052
+ return false;
9053
+ }
9054
+ /**
9055
+ * Esc in a picker step goes back one step instead of throwing the wizard
9056
+ * away — the listing is the expensive part and the user is still choosing.
9057
+ */
9058
+ stepBackOnboarding() {
9059
+ const state = this.onboarding;
9060
+ if (state === undefined)
9061
+ return false;
9062
+ if (state.step === 'models-pick') {
9063
+ state.step = 'models';
9064
+ this.markDirty();
9065
+ return true;
9066
+ }
9067
+ if (state.step === 'model-default') {
9068
+ state.step = 'models-pick';
9069
+ const checked = [...(state.modelChecked ?? new Set())];
9070
+ state.modelCursor = checked.length === 0 ? 0 : Math.min(...checked);
9071
+ this.markDirty();
9072
+ return true;
9073
+ }
9074
+ return false;
9075
+ }
7650
9076
  /** Fetch the endpoint's model list into the onboarding wizard's models step. */
7651
9077
  async fetchOnboardingModels() {
7652
9078
  const state = this.onboarding;
@@ -7691,6 +9117,11 @@ export class SshTui {
7691
9117
  this.pushRow({ kind: 'error', text: t('onboard.noModels') });
7692
9118
  }
7693
9119
  else {
9120
+ // Read the gateway's own route list while the key is still in hand: the
9121
+ // confirm step has no way to ask for it again.
9122
+ if (template.protocols !== undefined) {
9123
+ state.modelEndpoints = await fetchModelEndpoints(baseURL, key);
9124
+ }
7694
9125
  state.models = ids;
7695
9126
  state.modelCapacity = new Map(discovered.flatMap(model => {
7696
9127
  const capacity = {
@@ -7716,6 +9147,10 @@ export class SshTui {
7716
9147
  const state = this.onboarding;
7717
9148
  if (state === undefined)
7718
9149
  return;
9150
+ // `/setup` can switch the parent route (that is the point of adding a key),
9151
+ // so remember where it pointed before the wizard's answer is applied: the
9152
+ // subagent pin question below needs the old value.
9153
+ const previousParentProvider = this.currentProviderId();
7719
9154
  let saved = true;
7720
9155
  try {
7721
9156
  const credentials = this.ctx.get('credentials');
@@ -7724,14 +9159,14 @@ export class SshTui {
7724
9159
  if (state.providerType === 'official') {
7725
9160
  const envRef = 'DEEPSEEK_API_KEY';
7726
9161
  await this.saveCredential(credentials, envRef, state.key);
7727
- const model = state.models[0] ?? 'deepseek-v4-pro';
9162
+ const model = state.defaultModel ?? state.models[0] ?? 'deepseek-v4-pro';
7728
9163
  await this.ctx.get('agentDefaultModel')?.saveSelection({ provider: 'deepseek-official', model });
7729
9164
  if (this.selectionRef !== undefined) {
7730
9165
  this.selectionRef.current = { provider: 'deepseek-official', model };
7731
9166
  }
7732
9167
  this.onSelectionChanged?.({ provider: 'deepseek-official', model });
7733
9168
  await this.rememberRoute({ provider: 'deepseek-official', model });
7734
- await this.syncSubagentToProvider('deepseek-official', state.models);
9169
+ await this.syncSubagentToProvider('deepseek-official', state.models, previousParentProvider !== 'deepseek-official');
7735
9170
  if (state.baseUrl !== '' && settings !== undefined) {
7736
9171
  await settings.update(settingsNamespace('llm-deepseek'), { baseURL: state.baseUrl });
7737
9172
  this.pushRow({ kind: 'system', text: t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }) });
@@ -7745,7 +9180,7 @@ export class SshTui {
7745
9180
  }
7746
9181
  else {
7747
9182
  const envRef = envRefForId(state.providerId);
7748
- const model = state.models[0];
9183
+ const model = state.defaultModel ?? state.models[0];
7749
9184
  // OpenCode / third-party (llm-pi-ai) routes have no adapter-level
7750
9185
  // reasoning default. Re-running setup must not silently drop the
7751
9186
  // effort that makes thinking arrive as `reasoning` blocks; default it
@@ -7761,7 +9196,44 @@ export class SshTui {
7761
9196
  : await defaultReasoningEffort(llm, state.providerId, model);
7762
9197
  const reasoningEfforts = onboardingReasoningEfforts(state.providerType, defaultEffort === undefined ? undefined : String(defaultEffort));
7763
9198
  const existing = this.piAiProviderProfile(state.providerId);
7764
- 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);
7765
9237
  // The confirm step can be reached without walking the models step (and
7766
9238
  // a saved catalog answer may have landed after it), so size the picks
7767
9239
  // here too: the call is idempotent and fills only missing windows.
@@ -7790,36 +9262,74 @@ export class SshTui {
7790
9262
  // and an absent key defers to the provider's own environment auth.
7791
9263
  const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
7792
9264
  const keyless = catalogRoute && state.key === '';
7793
- const 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) => ({
7794
9283
  displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
7795
9284
  ? existing.displayName
7796
9285
  : template.label,
7797
9286
  ...(keyless ? {} : { apiKeyEnv: envRef }),
7798
- api: template.api ?? existing?.api,
7799
- ...(catalogRoute && state.baseUrl === ''
7800
- ? {}
7801
- : {
7802
- baseURL: state.baseUrl === ''
7803
- ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
7804
- : state.baseUrl,
7805
- }),
7806
- models: mergedIds.map(id => ({
9287
+ // A catalog route leaves the protocol to the installed catalog: it
9288
+ // used to write no `api` at all, and pinning one here would override
9289
+ // whatever that catalog serves.
9290
+ api: template.protocols === undefined ? (template.api ?? existing?.api) : protocol,
9291
+ ...(catalogRoute && state.baseUrl === '' ? {} : { baseURL }),
9292
+ models: models.map(id => ({
7807
9293
  id,
7808
9294
  ...(state.modelCapacity?.get(id) ?? template.defaultModelCapacity?.[id] ?? {}),
7809
9295
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
7810
9296
  })),
7811
9297
  ...(state.routeContextWindow === undefined ? {} : { defaultContextWindow: state.routeContextWindow }),
7812
9298
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
7813
- };
9299
+ });
9300
+ // Write every protocol that ends up holding models, plus any sibling
9301
+ // that exists but is now empty because its models moved elsewhere.
9302
+ const writes = [...split]
9303
+ .filter(([, models]) => models.length > 0)
9304
+ .map(([protocol, models]) => ({ protocol, models }));
9305
+ for (const sibling of siblingEntries) {
9306
+ // Only an entry that exists can be emptied: never create a sibling
9307
+ // just to say it holds nothing.
9308
+ if (sibling.profile === undefined)
9309
+ continue;
9310
+ if (!writes.some(entry => entry.protocol === sibling.protocol)) {
9311
+ writes.push({ protocol: sibling.protocol, models: [] });
9312
+ }
9313
+ }
9314
+ const entryId = (protocol) => protocol === baseProtocol ? state.providerId : siblingProviderId(state.providerId, protocol);
9315
+ // The route the wizard hands over must be the entry that actually lists
9316
+ // the chosen model — with a split they are no longer the same id.
9317
+ const ownerId = entryId(writes.find(entry => entry.models.includes(model))?.protocol ?? baseProtocol);
7814
9318
  if (settings === undefined) {
7815
9319
  this.pushRow({ kind: 'error', text: t('onboard.settingsMissing') });
7816
9320
  saved = false;
7817
9321
  }
7818
9322
  else {
7819
- await settings.mutate(settingsNamespace('llm-pi-ai'), [
7820
- { op: 'set', path: ['providers', state.providerId], value: profile },
7821
- ]);
9323
+ await settings.mutate(settingsNamespace('llm-pi-ai'), writes.map(entry => ({
9324
+ op: 'set',
9325
+ path: ['providers', entryId(entry.protocol)],
9326
+ value: profileFor(entry.protocol, entry.models),
9327
+ })));
7822
9328
  this.pushRow({ kind: 'system', text: t('onboard.providerSaved', { id: state.providerId, path: displayDshPath('settings.yaml') }) });
9329
+ if (template.protocols !== undefined) {
9330
+ const summary = writes.map(entry => `${GATEWAY_PROTOCOL_SUFFIX[entry.protocol]} ${entry.models.length}`).join(' · ');
9331
+ this.pushRow({ kind: 'system', text: t('onboard.providerSplit', { id: state.providerId, summary }) });
9332
+ }
7823
9333
  }
7824
9334
  // Only store the key when its provider profile actually made it to
7825
9335
  // settings; otherwise the saved key points at an unusable route.
@@ -7827,7 +9337,7 @@ export class SshTui {
7827
9337
  await this.saveCredential(credentials, envRef, state.key);
7828
9338
  if (saved) {
7829
9339
  const selection = {
7830
- provider: state.providerId,
9340
+ provider: ownerId,
7831
9341
  model,
7832
9342
  ...(defaultEffort === undefined ? {} : { reasoningEffort: defaultEffort }),
7833
9343
  };
@@ -7837,10 +9347,10 @@ export class SshTui {
7837
9347
  }
7838
9348
  this.onSelectionChanged?.(selection);
7839
9349
  await this.rememberRoute(selection);
7840
- await this.syncSubagentToProvider(state.providerId, state.models);
9350
+ await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
7841
9351
  this.pushRow({
7842
9352
  kind: 'system',
7843
- text: t('onboard.customDone', { id: state.providerId, model }),
9353
+ text: t('onboard.customDone', { id: ownerId, model }),
7844
9354
  });
7845
9355
  }
7846
9356
  }
@@ -7990,8 +9500,12 @@ export class SshTui {
7990
9500
  }
7991
9501
  if (this.dialog.kind === 'confirm')
7992
9502
  this.closeConfirm('cancel');
7993
- else if (this.dialog.kind === 'onboarding')
7994
- 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
+ }
7995
9509
  else
7996
9510
  this.dialog.reject(new UserQuestionError('ask_user_question was cancelled', 'ASK_ABORTED'));
7997
9511
  return;
@@ -8277,6 +9791,10 @@ export class SshTui {
8277
9791
  const text = this.input.trim();
8278
9792
  if (text === '')
8279
9793
  return;
9794
+ // Framed mode paints the composer; line mode has to put the typed line
9795
+ // into the log itself, or a `tee` never sees what the user sent.
9796
+ if (this.lineMode)
9797
+ this.pushRow({ kind: 'user', text: t('line.prompt', { text }) });
8280
9798
  if (text.startsWith('/')) {
8281
9799
  this.historyIndex = this.history.length;
8282
9800
  this.historyDraft = '';
@@ -8491,7 +10009,7 @@ export class SshTui {
8491
10009
  ...(quota === undefined ? {} : { quota }),
8492
10010
  ...(this.contextPressure === undefined ? {} : { context: this.contextPressure }),
8493
10011
  parentModel: model,
8494
- ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
10012
+ ...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
8495
10013
  subModel: sub.model,
8496
10014
  cwd: this.workspaceCwd(),
8497
10015
  });
@@ -8555,16 +10073,20 @@ export class SshTui {
8555
10073
  this.pushRow({ kind: 'system', text: t('sub.none') });
8556
10074
  }
8557
10075
  else {
8558
- const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => {
10076
+ const lines = [...this.activeSubagents.entries()].map(([, sub]) => {
8559
10077
  const card = this.findSubagentRow(sub.id);
8560
- const label = card?.label ?? sub.id;
8561
- 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
+ })();
8562
10083
  return t('sub.listLine', {
8563
10084
  label,
10085
+ // `/subagents kill` takes this id, and the list is where a user
10086
+ // looks it up: the chip itself stays name-only.
8564
10087
  id: sub.id,
8565
10088
  provider: sub.provider,
8566
10089
  seconds: Math.floor((Date.now() - sub.startedAt) / 1000),
8567
- run: runId.slice(0, 8),
8568
10090
  activity,
8569
10091
  });
8570
10092
  });