dsh-ssh-tui 0.7.1 → 0.7.3

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