@namzu/cli 20.0.0 → 22.0.0

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 (224) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +71 -10
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +24 -2
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/acp.d.ts.map +1 -1
  7. package/dist/commands/acp.js +6 -1
  8. package/dist/commands/acp.js.map +1 -1
  9. package/dist/commands/run-stream.d.ts.map +1 -1
  10. package/dist/commands/run-stream.js +9 -1
  11. package/dist/commands/run-stream.js.map +1 -1
  12. package/dist/commands/run.d.ts.map +1 -1
  13. package/dist/commands/run.js +15 -12
  14. package/dist/commands/run.js.map +1 -1
  15. package/dist/config/load.d.ts.map +1 -1
  16. package/dist/config/load.js +44 -2
  17. package/dist/config/load.js.map +1 -1
  18. package/dist/config/schema.d.ts +24 -13
  19. package/dist/config/schema.d.ts.map +1 -1
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/doctor/checks/chain.d.ts.map +1 -1
  22. package/dist/doctor/checks/chain.js +11 -5
  23. package/dist/doctor/checks/chain.js.map +1 -1
  24. package/dist/doctor/checks/credentials.d.ts.map +1 -1
  25. package/dist/doctor/checks/credentials.js +7 -1
  26. package/dist/doctor/checks/credentials.js.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/installation.d.ts +5 -0
  31. package/dist/installation.d.ts.map +1 -0
  32. package/dist/installation.js +48 -0
  33. package/dist/installation.js.map +1 -0
  34. package/dist/integrations/providers/access.d.ts +7 -0
  35. package/dist/integrations/providers/access.d.ts.map +1 -0
  36. package/dist/integrations/providers/access.js +17 -0
  37. package/dist/integrations/providers/access.js.map +1 -0
  38. package/dist/integrations/providers/current-credential.d.ts +4 -0
  39. package/dist/integrations/providers/current-credential.d.ts.map +1 -0
  40. package/dist/integrations/providers/current-credential.js +45 -0
  41. package/dist/integrations/providers/current-credential.js.map +1 -0
  42. package/dist/integrations/providers/discover.d.ts +28 -54
  43. package/dist/integrations/providers/discover.d.ts.map +1 -1
  44. package/dist/integrations/providers/discover.js +62 -58
  45. package/dist/integrations/providers/discover.js.map +1 -1
  46. package/dist/integrations/providers/gemini-credentials.d.ts +13 -0
  47. package/dist/integrations/providers/gemini-credentials.d.ts.map +1 -0
  48. package/dist/integrations/providers/gemini-credentials.js +148 -0
  49. package/dist/integrations/providers/gemini-credentials.js.map +1 -0
  50. package/dist/integrations/providers/harness-credentials.d.ts +14 -0
  51. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  52. package/dist/integrations/providers/harness-credentials.js +46 -0
  53. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  54. package/dist/integrations/providers/model-reasoning.d.ts +4 -0
  55. package/dist/integrations/providers/model-reasoning.d.ts.map +1 -0
  56. package/dist/integrations/providers/model-reasoning.js +46 -0
  57. package/dist/integrations/providers/model-reasoning.js.map +1 -0
  58. package/dist/integrations/providers/register.d.ts +1 -1
  59. package/dist/integrations/providers/register.d.ts.map +1 -1
  60. package/dist/integrations/providers/register.js +16 -1
  61. package/dist/integrations/providers/register.js.map +1 -1
  62. package/dist/integrations/providers/registry.d.ts +4 -4
  63. package/dist/integrations/providers/registry.d.ts.map +1 -1
  64. package/dist/integrations/providers/registry.js +33 -0
  65. package/dist/integrations/providers/registry.js.map +1 -1
  66. package/dist/integrations/providers/setup.d.ts +49 -0
  67. package/dist/integrations/providers/setup.d.ts.map +1 -0
  68. package/dist/integrations/providers/setup.js +105 -0
  69. package/dist/integrations/providers/setup.js.map +1 -0
  70. package/dist/integrations/sessions/context-inventory.d.ts +4 -0
  71. package/dist/integrations/sessions/context-inventory.d.ts.map +1 -0
  72. package/dist/integrations/sessions/context-inventory.js +44 -0
  73. package/dist/integrations/sessions/context-inventory.js.map +1 -0
  74. package/dist/integrations/sessions/conversation-search.d.ts +60 -0
  75. package/dist/integrations/sessions/conversation-search.d.ts.map +1 -0
  76. package/dist/integrations/sessions/conversation-search.js +461 -0
  77. package/dist/integrations/sessions/conversation-search.js.map +1 -0
  78. package/dist/integrations/sessions/store.d.ts +1 -0
  79. package/dist/integrations/sessions/store.d.ts.map +1 -1
  80. package/dist/integrations/sessions/store.js +17 -4
  81. package/dist/integrations/sessions/store.js.map +1 -1
  82. package/dist/integrations/sessions/task-context.d.ts +4 -0
  83. package/dist/integrations/sessions/task-context.d.ts.map +1 -0
  84. package/dist/integrations/sessions/task-context.js +73 -0
  85. package/dist/integrations/sessions/task-context.js.map +1 -0
  86. package/dist/integrations/subagents/activity.d.ts +3 -1
  87. package/dist/integrations/subagents/activity.d.ts.map +1 -1
  88. package/dist/integrations/subagents/activity.js +32 -5
  89. package/dist/integrations/subagents/activity.js.map +1 -1
  90. package/dist/integrations/subagents/history.d.ts +26 -0
  91. package/dist/integrations/subagents/history.d.ts.map +1 -0
  92. package/dist/integrations/subagents/history.js +76 -0
  93. package/dist/integrations/subagents/history.js.map +1 -0
  94. package/dist/integrations/subagents/model-effort.d.ts +4 -0
  95. package/dist/integrations/subagents/model-effort.d.ts.map +1 -0
  96. package/dist/integrations/subagents/model-effort.js +12 -0
  97. package/dist/integrations/subagents/model-effort.js.map +1 -0
  98. package/dist/integrations/subagents/runtime.d.ts +27 -7
  99. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  100. package/dist/integrations/subagents/runtime.js +305 -35
  101. package/dist/integrations/subagents/runtime.js.map +1 -1
  102. package/dist/integrations/web/search.d.ts +11 -0
  103. package/dist/integrations/web/search.d.ts.map +1 -0
  104. package/dist/integrations/web/search.js +73 -0
  105. package/dist/integrations/web/search.js.map +1 -0
  106. package/dist/memory/io.d.ts +12 -0
  107. package/dist/memory/io.d.ts.map +1 -0
  108. package/dist/memory/io.js +177 -0
  109. package/dist/memory/io.js.map +1 -0
  110. package/dist/memory/presentation.d.ts +4 -1
  111. package/dist/memory/presentation.d.ts.map +1 -1
  112. package/dist/memory/presentation.js +16 -2
  113. package/dist/memory/presentation.js.map +1 -1
  114. package/dist/memory/store.d.ts +17 -3
  115. package/dist/memory/store.d.ts.map +1 -1
  116. package/dist/memory/store.js +61 -35
  117. package/dist/memory/store.js.map +1 -1
  118. package/dist/output-schema.d.ts +4 -0
  119. package/dist/output-schema.d.ts.map +1 -0
  120. package/dist/output-schema.js +18 -0
  121. package/dist/output-schema.js.map +1 -0
  122. package/dist/tui/AgentExplorer.d.ts +13 -2
  123. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  124. package/dist/tui/AgentExplorer.js +85 -23
  125. package/dist/tui/AgentExplorer.js.map +1 -1
  126. package/dist/tui/App.d.ts.map +1 -1
  127. package/dist/tui/App.js +694 -150
  128. package/dist/tui/App.js.map +1 -1
  129. package/dist/tui/BrandHeader.d.ts +1 -4
  130. package/dist/tui/BrandHeader.d.ts.map +1 -1
  131. package/dist/tui/BrandHeader.js +4 -8
  132. package/dist/tui/BrandHeader.js.map +1 -1
  133. package/dist/tui/Composer.d.ts +2 -1
  134. package/dist/tui/Composer.d.ts.map +1 -1
  135. package/dist/tui/Composer.js +26 -3
  136. package/dist/tui/Composer.js.map +1 -1
  137. package/dist/tui/PermissionOverlay.d.ts +5 -2
  138. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  139. package/dist/tui/PermissionOverlay.js +14 -6
  140. package/dist/tui/PermissionOverlay.js.map +1 -1
  141. package/dist/tui/Picker.d.ts +2 -1
  142. package/dist/tui/Picker.d.ts.map +1 -1
  143. package/dist/tui/Picker.js +104 -24
  144. package/dist/tui/Picker.js.map +1 -1
  145. package/dist/tui/ProviderSetup.d.ts +6 -0
  146. package/dist/tui/ProviderSetup.d.ts.map +1 -0
  147. package/dist/tui/ProviderSetup.js +125 -0
  148. package/dist/tui/ProviderSetup.js.map +1 -0
  149. package/dist/tui/ResumePicker.d.ts.map +1 -1
  150. package/dist/tui/ResumePicker.js +5 -3
  151. package/dist/tui/ResumePicker.js.map +1 -1
  152. package/dist/tui/StatusPanel.d.ts +5 -0
  153. package/dist/tui/StatusPanel.d.ts.map +1 -0
  154. package/dist/tui/StatusPanel.js +11 -0
  155. package/dist/tui/StatusPanel.js.map +1 -0
  156. package/dist/tui/TextPrompt.d.ts +3 -1
  157. package/dist/tui/TextPrompt.d.ts.map +1 -1
  158. package/dist/tui/TextPrompt.js +55 -9
  159. package/dist/tui/TextPrompt.js.map +1 -1
  160. package/dist/tui/ToolOutputViewer.d.ts +11 -0
  161. package/dist/tui/ToolOutputViewer.d.ts.map +1 -0
  162. package/dist/tui/ToolOutputViewer.js +39 -0
  163. package/dist/tui/ToolOutputViewer.js.map +1 -0
  164. package/dist/tui/Transcript.d.ts.map +1 -1
  165. package/dist/tui/Transcript.js +10 -4
  166. package/dist/tui/Transcript.js.map +1 -1
  167. package/dist/tui/agent.d.ts +24 -3
  168. package/dist/tui/agent.d.ts.map +1 -1
  169. package/dist/tui/agent.js +362 -97
  170. package/dist/tui/agent.js.map +1 -1
  171. package/dist/tui/live-window.d.ts.map +1 -1
  172. package/dist/tui/live-window.js +5 -1
  173. package/dist/tui/live-window.js.map +1 -1
  174. package/dist/tui/logo.d.ts +4 -4
  175. package/dist/tui/logo.d.ts.map +1 -1
  176. package/dist/tui/logo.js +4 -4
  177. package/dist/tui/logo.js.map +1 -1
  178. package/dist/tui/model-catalogue-view.d.ts +3 -0
  179. package/dist/tui/model-catalogue-view.d.ts.map +1 -0
  180. package/dist/tui/model-catalogue-view.js +50 -0
  181. package/dist/tui/model-catalogue-view.js.map +1 -0
  182. package/dist/tui/model-choices.d.ts +3 -1
  183. package/dist/tui/model-choices.d.ts.map +1 -1
  184. package/dist/tui/model-choices.js +12 -1
  185. package/dist/tui/model-choices.js.map +1 -1
  186. package/dist/tui/model-search.d.ts +4 -0
  187. package/dist/tui/model-search.d.ts.map +1 -0
  188. package/dist/tui/model-search.js +11 -0
  189. package/dist/tui/model-search.js.map +1 -0
  190. package/dist/tui/model-selection-intent.d.ts +8 -0
  191. package/dist/tui/model-selection-intent.d.ts.map +1 -0
  192. package/dist/tui/model-selection-intent.js +96 -0
  193. package/dist/tui/model-selection-intent.js.map +1 -0
  194. package/dist/tui/model-switch-tool.d.ts +7 -0
  195. package/dist/tui/model-switch-tool.d.ts.map +1 -0
  196. package/dist/tui/model-switch-tool.js +74 -0
  197. package/dist/tui/model-switch-tool.js.map +1 -0
  198. package/dist/tui/model-switch.d.ts +35 -0
  199. package/dist/tui/model-switch.d.ts.map +1 -0
  200. package/dist/tui/model-switch.js +106 -0
  201. package/dist/tui/model-switch.js.map +1 -0
  202. package/dist/tui/permission-review.d.ts +2 -0
  203. package/dist/tui/permission-review.d.ts.map +1 -1
  204. package/dist/tui/permission-review.js +82 -15
  205. package/dist/tui/permission-review.js.map +1 -1
  206. package/dist/tui/run-interruption.d.ts +7 -0
  207. package/dist/tui/run-interruption.d.ts.map +1 -1
  208. package/dist/tui/run-interruption.js +41 -0
  209. package/dist/tui/run-interruption.js.map +1 -1
  210. package/dist/tui/slashCommands.d.ts +7 -0
  211. package/dist/tui/slashCommands.d.ts.map +1 -1
  212. package/dist/tui/slashCommands.js +77 -2
  213. package/dist/tui/slashCommands.js.map +1 -1
  214. package/dist/tui/status-card.d.ts +3 -0
  215. package/dist/tui/status-card.d.ts.map +1 -0
  216. package/dist/tui/status-card.js +30 -0
  217. package/dist/tui/status-card.js.map +1 -0
  218. package/dist/tui/status-panel-layout.d.ts +11 -0
  219. package/dist/tui/status-panel-layout.d.ts.map +1 -0
  220. package/dist/tui/status-panel-layout.js +42 -0
  221. package/dist/tui/status-panel-layout.js.map +1 -0
  222. package/dist/tui/types.d.ts +7 -1
  223. package/dist/tui/types.d.ts.map +1 -1
  224. package/package.json +9 -7
package/dist/tui/App.js CHANGED
@@ -11,6 +11,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
11
11
  * 4. Otherwise show <Picker/>. An explicit picker choice is persisted;
12
12
  * automatic reuse is not mistaken for a durable operator preference.
13
13
  */
14
+ import { discoverProviders } from '../integrations/providers/discover.js';
15
+ import { ProviderSetup } from './ProviderSetup.js';
16
+ import { ToolOutputViewer } from './ToolOutputViewer.js';
14
17
  import { join, relative } from 'node:path';
15
18
  import { DEFAULT_MAX_GOAL_ROUNDS, DefaultPathBuilder, DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, generateSessionId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
19
  import { Box, Text, useApp, useInput, useStdout, useWindowSize } from 'ink';
@@ -25,12 +28,12 @@ import { appendMessages, archiveConversation, forkConversation, forkConversation
25
28
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
26
29
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
27
30
  import { checkUpdates } from '../integrations/updates.js';
28
- import { renderMemoryReport } from '../memory/presentation.js';
29
- import { appendMemory, readMemory } from '../memory/store.js';
31
+ import { renderMemoryReport, renderMemorySaveResult } from '../memory/presentation.js';
32
+ import { appendMemoryWithStatus, readMemory } from '../memory/store.js';
30
33
  import { effectivePermissionMode, permissionModeDescription, permissionModeLabel, } from '../permissions/mode.js';
31
34
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
32
35
  import { discoverUserCommands } from '../user-commands/store.js';
33
- import { AgentCockpit, AgentTaskPanel, AgentTranscript, activeSubagentCohorts, agentCockpitIsWide, agentPhasePageSize, agentPhases, agentPickerPageSize, agentTranscriptPageSize, maxAgentTranscriptTailOffset, } from './AgentExplorer.js';
36
+ import { AgentCockpit, AgentTaskPanel, AgentTranscript, activeSubagentCohorts, agentCockpitIsWide, agentPhasePageSize, agentPhases, agentPickerPageSize, agentTranscriptPageSize, agentWorkflowPageSize, agentWorkflows, maxAgentTranscriptTailOffset, } from './AgentExplorer.js';
34
37
  import { BrandHeader } from './BrandHeader.js';
35
38
  import { ChoicePicker } from './ChoicePicker.js';
36
39
  import { Composer, suggestionWindowSize, } from './Composer.js';
@@ -44,9 +47,10 @@ import { ResumePicker } from './ResumePicker.js';
44
47
  import { StatusBar } from './StatusBar.js';
45
48
  import { TaskList } from './TaskList.js';
46
49
  import { TextPrompt } from './TextPrompt.js';
50
+ import { modelCatalogueView } from './model-catalogue-view.js';
47
51
  import { Transcript, willCollapse } from './Transcript.js';
48
52
  import { TrustPrompt } from './TrustPrompt.js';
49
- import { createAgentSession, probeAgentSession, } from './agent.js';
53
+ import { createAgentSession, describeProviderModels, probeAgentSession, } from './agent.js';
50
54
  import { choicePickerWindowSize, filterChoiceOptions, moveChoiceSelection, } from './choice-selection.js';
51
55
  import { keepRecentRows } from './compact-transcript.js';
52
56
  import { approvalIsDeliberate } from './consent-timing.js';
@@ -55,6 +59,8 @@ import { copyTargetsForResponse } from './copy-targets.js';
55
59
  import { editablePrompts } from './edit-prompts.js';
56
60
  import { editDraftInExternalEditor } from './external-editor.js';
57
61
  import { liveWindow } from './live-window.js';
62
+ import { resolveModelSwitch, } from './model-switch.js';
63
+ import { parseModelSelectionIntent, resolveModelSelectionIntent } from './model-selection-intent.js';
58
64
  import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
59
65
  import { selectPrimaryProvider } from './provider-selection.js';
60
66
  import { eraseLastChoiceGrapheme } from './terminal-choice-text.js';
@@ -62,7 +68,7 @@ import { renderCheckpoints, renderRestore } from '../checkpoints/store.js';
62
68
  import { expandFileMentions, listMentionableFiles } from './mentions.js';
63
69
  import { openInBrowser } from './open-browser.js';
64
70
  import { buildPermissionReview, buildPermissionSummary, permissionReviewPageRows, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
65
- import { describeRunInterruption } from './run-interruption.js';
71
+ import { describeRunInterruption, describeRunStop } from './run-interruption.js';
66
72
  import { moveSelection } from './selection-window.js';
67
73
  import { describeShellEscape, describeShellEscapeForModel, runShellEscape, shellEscapeCommand, } from './shell-escape.js';
68
74
  import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
@@ -275,6 +281,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
275
281
  const [messages, setMessages] = useState([]);
276
282
  /** Plain, copy-friendly rendering of the same retained transcript rows. */
277
283
  const [rawOutput, setRawOutput] = useState(false);
284
+ const [outputViewer, setOutputViewer] = useState(null);
278
285
  /**
279
286
  * The conversation sent to the model, in the SDK's own lossless shape.
280
287
  *
@@ -531,6 +538,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
531
538
  copyPickerCommittedRef.current = copyPicker;
532
539
  }, [copyPicker]);
533
540
  /** Finite slash-command choice owned synchronously until apply or cancel. */
541
+ const [providerSetup, setProviderSetup] = useState(false);
534
542
  const [choicePicker, setChoicePickerState] = useState(null);
535
543
  const choicePickerRef = useRef(null);
536
544
  const [choiceQuery, setChoiceQuery] = useState('');
@@ -587,6 +595,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
587
595
  agentSurfaceCommittedRef.current = agentSurface;
588
596
  }, [agentSurface]);
589
597
  const replaceSubagents = useCallback((next) => {
598
+ const previous = subagentsRef.current;
590
599
  subagentsRef.current = next;
591
600
  setSubagentsState(next);
592
601
  const surface = agentSurfaceRef.current;
@@ -599,7 +608,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
599
608
  if (next.some((agent) => agent.viewId === surface.selectedId))
600
609
  return;
601
610
  const phases = agentPhases(next);
602
- const fallbackPhase = phases.find((phase) => phase.id === surface.selectedPhaseId) ?? phases[0];
611
+ const previousGroupId = previous.find((agent) => agent.viewId === surface.selectedId)?.workflowGroupId;
612
+ const sameWorkflow = agentWorkflows(next).find((workflow) => workflow.id === previousGroupId);
613
+ const fallbackPhase = phases.find((phase) => phase.id === surface.selectedPhaseId) ?? sameWorkflow?.phases[0] ?? phases[0];
603
614
  const fallbackAgent = fallbackPhase?.agents[0];
604
615
  if (!fallbackPhase || !fallbackAgent) {
605
616
  setAgentSurface(null);
@@ -614,7 +625,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
614
625
  }, [setAgentSurface]);
615
626
  const openAgentCockpit = useCallback(() => {
616
627
  const agents = subagentsRef.current;
617
- const firstPhase = agentPhases(agents)[0];
628
+ const workflows = agentWorkflows(agents);
629
+ const currentWorkflow = [...workflows].reverse().find((workflow) => workflow.agents.some((agent) => agent.status === 'starting' || agent.status === 'queued' || agent.status === 'working')) ?? workflows.at(-1);
630
+ const firstPhase = currentWorkflow?.phases[0];
618
631
  const firstAgent = firstPhase?.agents[0];
619
632
  if (!firstPhase || !firstAgent)
620
633
  return false;
@@ -622,7 +635,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
622
635
  kind: 'cockpit',
623
636
  selectedPhaseId: firstPhase.id,
624
637
  selectedId: firstAgent.viewId,
625
- focus: 'agents',
638
+ focus: workflows.length > 1 ? 'workflows' : 'agents',
626
639
  });
627
640
  return true;
628
641
  }, [setAgentSurface]);
@@ -632,6 +645,18 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
632
645
  const abortRef = useRef(null);
633
646
  /** Identity of the turn allowed to notify when it settles. */
634
647
  const activeTurnTokenRef = useRef(null);
648
+ /** A tool can reserve a switch; only the owning turn's finalizer may publish it. */
649
+ const pendingModelSwitchRef = useRef(null);
650
+ const [modelSwitchVersion, setModelSwitchVersion] = useState(0);
651
+ const cancelPendingModelSwitch = useCallback(() => {
652
+ const pending = pendingModelSwitchRef.current;
653
+ if (!pending)
654
+ return false;
655
+ pendingModelSwitchRef.current = null;
656
+ pending.controller.abort(new Error('The pending model switch was cancelled.'));
657
+ setModelSwitchVersion((version) => version + 1);
658
+ return true;
659
+ }, []);
635
660
  /** Exact live-input owner; absent before provider admission and after settlement. */
636
661
  const activeTurnInboxRef = useRef(null);
637
662
  /** A broken terminal notification is reported once, not after every turn. */
@@ -689,6 +714,18 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
689
714
  setActiveTools([]);
690
715
  }, []);
691
716
  const permissionResolveRef = useRef(null);
717
+ const permissionQueueRef = useRef([]);
718
+ const [queuedPermissionCount, setQueuedPermissionCount] = useState(0);
719
+ useEffect(() => () => {
720
+ const decision = {
721
+ kind: 'reject',
722
+ feedback: 'The approval screen closed.',
723
+ };
724
+ permissionResolveRef.current?.(decision);
725
+ permissionResolveRef.current = null;
726
+ for (const pending of permissionQueueRef.current.splice(0))
727
+ pending.resolve(decision);
728
+ }, []);
692
729
  /**
693
730
  * When the pending prompt took the screen, or `null` with none open.
694
731
  *
@@ -727,13 +764,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
727
764
  * hook never ran.
728
765
  */
729
766
  const closeAndExit = useCallback(() => {
767
+ cancelPendingModelSwitch();
730
768
  const closing = session?.close() ?? Promise.resolve();
731
769
  const bounded = Promise.race([
732
770
  closing,
733
771
  new Promise((resolve) => setTimeout(resolve, SESSION_CLOSE_ON_EXIT_MS).unref?.()),
734
772
  ]);
735
773
  void bounded.catch(() => undefined).finally(() => exit());
736
- }, [exit, session]);
774
+ }, [cancelPendingModelSwitch, exit, session]);
737
775
  const exitWithSummary = useCallback(() => {
738
776
  onExitSummary?.({
739
777
  ...(conversationMaterializedRef.current && scopeRef.current?.sessionId
@@ -747,6 +785,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
747
785
  const goalDriveInFlightRef = useRef(false);
748
786
  const [goalDriveVersion, setGoalDriveVersion] = useState(0);
749
787
  const [goalStatus, setGoalStatus] = useState(null);
788
+ const pendingGoalResumeRef = useRef(null);
750
789
  const wakeGoalDriver = useCallback(() => setGoalDriveVersion((version) => version + 1), []);
751
790
  /**
752
791
  * Conversation writes in the order the operator produced them.
@@ -837,6 +876,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
837
876
  settledRef.current = 0;
838
877
  setResetKey((k) => k + 1);
839
878
  }, [stdout.isTTY, writeStdout]);
879
+ const previousTerminalRef = useRef(terminal);
880
+ useEffect(() => {
881
+ const previous = previousTerminalRef.current;
882
+ previousTerminalRef.current = terminal;
883
+ if (externalEditorRequestRef.current === null &&
884
+ (terminal.columns < previous.columns || terminal.rows < previous.rows)) {
885
+ // A contraction can reflow live rows into native scrollback before Ink
886
+ // receives resize. Its old row count cannot erase those rows. Rebuild
887
+ // Namzu's normal-buffer view at the committed size, retaining the same
888
+ // messages, draft and selected child. Regular frames remain incremental.
889
+ resetTranscript();
890
+ }
891
+ }, [terminal, resetTranscript]);
840
892
  const requestExternalEditor = useCallback((seed) => {
841
893
  if (phase !== 'ready' ||
842
894
  state !== 'idle' ||
@@ -907,7 +959,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
907
959
  externalEditorRequestRef.current = null;
908
960
  request.controller.abort(new Error('the terminal session closed'));
909
961
  }, []);
910
- const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
962
+ const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta, activity) => {
911
963
  const id = nextId();
912
964
  setMessages((prev) => [
913
965
  ...prev,
@@ -920,6 +972,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
920
972
  detail,
921
973
  glyphColor,
922
974
  meta,
975
+ activity,
923
976
  // Numbered only if this body will actually be COLLAPSED — the
924
977
  // number exists to be read off a hint, and a body that fits
925
978
  // prints no hint. Numbering every body instead would leave gaps
@@ -930,7 +983,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
930
983
  //
931
984
  // Derived from `prev` rather than a counter, so the number is a
932
985
  // fact about the transcript rather than a second record of it.
933
- ...(willCollapse(detail)
986
+ ...((activity && detail?.length) || willCollapse(detail)
934
987
  ? {
935
988
  detailRef: prev.filter((m) => m.detailRef !== undefined).length + 1,
936
989
  }
@@ -939,6 +992,29 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
939
992
  ]);
940
993
  return id;
941
994
  }, [nextId]);
995
+ const reportedAgentsRef = useRef(new Set());
996
+ useEffect(() => {
997
+ reportedAgentsRef.current.clear();
998
+ }, [session]);
999
+ useEffect(() => {
1000
+ const current = session?.subagents?.getSnapshot() ?? [];
1001
+ for (const agent of subagents) {
1002
+ if (!current.some((item) => item.viewId === agent.viewId &&
1003
+ item.workflowId === agent.workflowId &&
1004
+ item.status === agent.status))
1005
+ continue;
1006
+ if (!['completed', 'failed', 'cancelled'].includes(agent.status))
1007
+ continue;
1008
+ if (reportedAgentsRef.current.has(agent.viewId))
1009
+ continue;
1010
+ reportedAgentsRef.current.add(agent.viewId);
1011
+ const completed = agent.status === 'completed';
1012
+ const status = completed
1013
+ ? 'Completed'
1014
+ : (agent.latestActivity ?? (agent.status === 'cancelled' ? 'Cancelled' : 'Failed'));
1015
+ pushMessage('tool', `${agent.description} · ${status}`, false, completed ? '✓' : '✗', undefined, completed ? theme.status.ok : theme.status.error, 'ctrl+t · agent details');
1016
+ }
1017
+ }, [subagents, pushMessage, session]);
942
1018
  const applyPermissionMode = useCallback((mode) => {
943
1019
  if (!session?.hasProvider) {
944
1020
  pushMessage('system', 'Choose a model before changing permissions.');
@@ -963,15 +1039,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
963
1039
  setPermissionModeState(mode);
964
1040
  pushMessage('system', `Permissions: ${permissionModeLabel(mode)} for this session. ${permissionModeDescription(mode)}`);
965
1041
  }, [hasUnsettledTurn, pushMessage, session, state]);
966
- const applyReasoningEffort = useCallback((effort) => {
1042
+ const applyReasoningEffort = useCallback((effort, selectedSession) => {
967
1043
  if (!session?.hasProvider) {
968
1044
  pushMessage('system', 'No active session — pick a provider before changing reasoning effort.');
969
1045
  return;
970
1046
  }
1047
+ if (selectedSession !== undefined && selectedSession !== session) {
1048
+ pushMessage('system', 'Reasoning effort was not changed: the selected model is no longer active.');
1049
+ return;
1050
+ }
971
1051
  if (state !== 'idle' ||
972
1052
  abortRef.current !== null ||
973
1053
  hasUnsettledTurn() ||
974
- queuedRef.current.length > 0 ||
1054
+ (queuedRef.current.length > 0 && selectedSession !== session) ||
975
1055
  permissionResolveRef.current !== null ||
976
1056
  compactingRef.current) {
977
1057
  pushMessage('system', 'Reasoning effort was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
@@ -979,7 +1059,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
979
1059
  }
980
1060
  if (effort === undefined) {
981
1061
  setReasoningEffort(undefined);
982
- pushMessage('system', 'Reasoning effort reset to the provider default for future main-query turns.');
1062
+ pushMessage('system', 'Reasoning: provider default.');
983
1063
  return;
984
1064
  }
985
1065
  if (!session.reasoningEffortLevels?.includes(effort)) {
@@ -987,7 +1067,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
987
1067
  return;
988
1068
  }
989
1069
  setReasoningEffort(effort);
990
- pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
1070
+ pushMessage('system', `Reasoning: ${effort} (this session).`);
991
1071
  }, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
992
1072
  const stepReasoningEffort = useCallback((direction) => {
993
1073
  const levels = session?.reasoningEffortLevels;
@@ -1412,6 +1492,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1412
1492
  return;
1413
1493
  }
1414
1494
  setChoicePicker(null);
1495
+ if (picker.kind === 'resume-goal') {
1496
+ if (value === 'resume' && scopeRef.current?.sessionId === picker.sessionId)
1497
+ commandPickerSubmitRef.current('/goal resume');
1498
+ return;
1499
+ }
1415
1500
  if (picker.kind === 'command') {
1416
1501
  const command = value;
1417
1502
  if (command.problem) {
@@ -1477,7 +1562,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1477
1562
  removeStoredCredential(value);
1478
1563
  return;
1479
1564
  }
1480
- applyReasoningEffort(value);
1565
+ applyReasoningEffort(value, picker.selectedSession);
1481
1566
  }, [
1482
1567
  activateSkill,
1483
1568
  advanceQueueContinuation,
@@ -1609,6 +1694,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1609
1694
  lastCompletedOutputRef.current = persistedOutput
1610
1695
  ? { text: persistedOutput, provenance: 'persisted' }
1611
1696
  : null;
1697
+ pendingGoalResumeRef.current = requestedConversationId;
1612
1698
  initialConversationIdRef.current = undefined;
1613
1699
  conversationMaterializedRef.current = true;
1614
1700
  }
@@ -1672,7 +1758,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1672
1758
  materializationRef.current = null;
1673
1759
  }
1674
1760
  }, [ensureSessions]);
1675
- const hydrateSession = useCallback(async (prefs, detectedNow, signal, persistSelection = false) => {
1761
+ const hydrateSession = useCallback(async (prefs, detectedNow, options = {}) => {
1762
+ const { signal, persistSelection = false, announce = false } = options;
1676
1763
  if (signal?.aborted)
1677
1764
  return;
1678
1765
  const scope = await ensureSessions();
@@ -1682,22 +1769,27 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1682
1769
  const s = await createAgentSession(prefs, detectedNow, {
1683
1770
  scope,
1684
1771
  cwd: activeCtx.cwd,
1772
+ ...(activeCtx.structuredOutput ? { structuredOutput: activeCtx.structuredOutput } : {}),
1685
1773
  ...(activeCtx.additionalDirectories
1686
1774
  ? { additionalDirectories: activeCtx.additionalDirectories }
1687
1775
  : {}),
1688
1776
  ...(sessionsRef.current ? { stateRoot: sessionsRef.current.root } : {}),
1689
1777
  enableComputerUse: true,
1690
1778
  rules: activeCtx.rules,
1691
- ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
1779
+ ...(sessionsRef.current
1780
+ ? { sessionGoals: sessionsRef.current.goals, conversationSessions: sessionsRef.current }
1781
+ : {}),
1692
1782
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
1693
1783
  ...(activeCtx.plugins ? { plugins: activeCtx.plugins } : {}),
1694
1784
  ...(activeCtx.web ? { web: activeCtx.web } : {}),
1695
1785
  ...(activeCtx.hooks ? { hooks: activeCtx.hooks } : {}),
1696
1786
  ...(activeCtx.compaction ? { compaction: activeCtx.compaction } : {}),
1787
+ ...(activeCtx.memory ? { memory: activeCtx.memory } : {}),
1697
1788
  ...(activeCtx.sandbox ? { sandbox: activeCtx.sandbox } : {}),
1698
1789
  // Somebody is at this terminal, so the model may ask them one
1699
1790
  // question when a decision is genuinely theirs.
1700
1791
  askUser: true,
1792
+ allowModelSwitch: true,
1701
1793
  });
1702
1794
  if (signal?.aborted) {
1703
1795
  void s.close();
@@ -1716,6 +1808,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1716
1808
  }
1717
1809
  let commands;
1718
1810
  try {
1811
+ options.beforePublish?.();
1719
1812
  commands = discoverUserCommands({ cwd: activeCtx.cwd, reserved: hostCommandNames() });
1720
1813
  // Construction and validation precede the saved change. There is no
1721
1814
  // await between this atomic write and publishing the usable session.
@@ -1741,9 +1834,16 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1741
1834
  // Re-hydration (a provider switch via /model) builds a second session;
1742
1835
  // without this the first one's tool-server child processes stay alive
1743
1836
  // for the rest of the TUI's life.
1837
+ const previousInstructionFiles = previousSessionRef.current?.instructionFiles;
1744
1838
  void previousSessionRef.current?.close();
1745
1839
  previousSessionRef.current = s;
1746
1840
  setSession(s);
1841
+ if (options.chooseReasoningEffort && s.reasoningEffortLevels?.length) {
1842
+ // Own input before publishing ready or releasing a paused queue. The
1843
+ // menu closes only after choosing an effort or keeping the new default.
1844
+ setSelectedChoice(0);
1845
+ setChoicePicker(reasoningEffortPicker(s, undefined, true) ?? null);
1846
+ }
1747
1847
  setUserCommands(commands);
1748
1848
  const mentionLoadOwner = {};
1749
1849
  mentionLoadOwnerRef.current = mentionLoadOwner;
@@ -1760,13 +1860,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1760
1860
  if (s.hasProvider) {
1761
1861
  setTranscriptOwned(true);
1762
1862
  setPhase('ready');
1763
- pushMessage('system',
1764
- // Counted here, at connect. The number is deliberately the one true
1765
- // at this moment rather than the one that will be true after the
1766
- // first turn registers the deferred tools — a line reporting that a
1767
- // connection just happened should describe the connection that just
1768
- // happened. `/tools` is the present-tense question and asks later.
1769
- `Connected to ${s.providerSummary}${s.modelSummary ? ` · ${s.modelSummary}` : ''} · ${s.toolNames().length} tools`);
1863
+ // Startup identity already lives in the footer. Confirm an explicit
1864
+ // picker selection, but do not add a routine connection log to chat.
1865
+ if (announce) {
1866
+ pushMessage('system', `Connected to ${s.providerSummary}${s.modelSummary ? ` · ${s.modelSummary}` : ''}`);
1867
+ }
1770
1868
  // Before the rest: a limitation the operator accepted once and has
1771
1869
  // been living with since is the thing they are least likely to
1772
1870
  // remember and most likely to be surprised by.
@@ -1778,7 +1876,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1778
1876
  // they did not expect — an instructions file in a parent directory
1779
1877
  // they forgot about is exactly the thing that makes the agent
1780
1878
  // behave oddly for no visible reason.
1781
- if (s.instructionFiles.length > 0) {
1879
+ if (s.instructionFiles.length > 0 &&
1880
+ JSON.stringify([...s.instructionFiles].sort()) !==
1881
+ JSON.stringify(previousInstructionFiles ? [...previousInstructionFiles].sort() : undefined)) {
1782
1882
  pushMessage('system', `Project instructions: ${s.instructionFiles.map((p) => relative(activeCtx.cwd, p) || p).join(', ')}`);
1783
1883
  }
1784
1884
  for (const skip of s.skippedInstructionFiles) {
@@ -1799,7 +1899,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1799
1899
  if (s.errorHint)
1800
1900
  pushMessage('system', s.errorHint);
1801
1901
  }
1802
- }, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
1902
+ }, [
1903
+ appLifetime.signal,
1904
+ advanceQueueContinuation,
1905
+ ensureSessions,
1906
+ pushMessage,
1907
+ setChoicePicker,
1908
+ setReasoningEffort,
1909
+ setSelectedChoice,
1910
+ ]);
1803
1911
  /**
1804
1912
  * Sign in to a subscription without leaving namzu.
1805
1913
  *
@@ -1835,7 +1943,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1835
1943
  pushMessage('system', describeLoginOutcome(outcome));
1836
1944
  if (outcome.ok) {
1837
1945
  setPickerInitialView('providers');
1838
- await runProbeRef.current?.();
1946
+ await runProbeRef.current?.({ announce: true });
1839
1947
  }
1840
1948
  return;
1841
1949
  }
@@ -1894,7 +2002,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1894
2002
  setPickerNotice(describeLoginOutcome(outcome));
1895
2003
  if (outcome.ok) {
1896
2004
  setPickerInitialView('providers');
1897
- await runProbeRef.current?.(signal);
2005
+ await runProbeRef.current?.({ signal, announce: true });
1898
2006
  }
1899
2007
  return 'finished';
1900
2008
  }
@@ -1954,11 +2062,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1954
2062
  setPickerNotice(describeLoginOutcome(outcome));
1955
2063
  if (outcome.ok) {
1956
2064
  setPickerInitialView('providers');
1957
- await runProbeRef.current?.(signal);
2065
+ await runProbeRef.current?.({ signal, announce: true });
1958
2066
  }
1959
2067
  return 'finished';
1960
2068
  }, []);
1961
- const runProbe = useCallback(async (signal) => {
2069
+ const runProbe = useCallback(async (options = {}) => {
2070
+ const { signal, announce = false } = options;
1962
2071
  try {
1963
2072
  if (signal?.aborted)
1964
2073
  return;
@@ -2011,7 +2120,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2011
2120
  if (probe.preferences) {
2012
2121
  setPickerDetected(null);
2013
2122
  setPickerSelectionKind('provider-and-model');
2014
- await hydrateSession(probe.preferences, probe.detected, signal);
2123
+ await hydrateSession(probe.preferences, probe.detected, { signal, announce });
2015
2124
  return;
2016
2125
  }
2017
2126
  const signedIn = signedInSubscriptionProviders(probe.detected);
@@ -2029,7 +2138,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2029
2138
  // not strand first run on the disabled unhealthy screen.
2030
2139
  const admissionSignal = signal ?? appLifetime.signal;
2031
2140
  try {
2032
- await hydrateSession(automaticPreferences, probe.detected, admissionSignal);
2141
+ await hydrateSession(automaticPreferences, probe.detected, {
2142
+ signal: admissionSignal,
2143
+ announce,
2144
+ });
2033
2145
  return;
2034
2146
  }
2035
2147
  catch (error) {
@@ -2047,7 +2159,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2047
2159
  setPickerDetected(signedIn);
2048
2160
  setPickerSelectionKind('signed-in-subscription');
2049
2161
  setPickerInitialView('providers');
2050
- setPickerNotice('Claude and Codex subscriptions are already signed in on this device. Choose which one Namzu should use.');
2162
+ setPickerNotice('Multiple model sessions are already signed in on this device. Choose which one Namzu should use.');
2051
2163
  setPhase('picker');
2052
2164
  return;
2053
2165
  }
@@ -2117,6 +2229,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2117
2229
  // terminal scrollback; the live tail stays deliberately small so an activity
2118
2230
  // tick never repaints the whole conversation.
2119
2231
  //
2232
+ useEffect(() => {
2233
+ setOutputViewer(null);
2234
+ }, [resetKey]);
2235
+ useEffect(() => {
2236
+ if (permission !== null)
2237
+ setOutputViewer(null);
2238
+ }, [permission]);
2120
2239
  // A ref, and mutated during render, because the split has to be MONOTONIC:
2121
2240
  // a row that has been printed to scrollback can never come back, and `max`
2122
2241
  // is idempotent, so a repeated render reaches the same answer.
@@ -2132,9 +2251,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2132
2251
  // Freeze the parent's Static floor while it is open; otherwise a parent turn
2133
2252
  // settling in the background prints through the child screen. Returning
2134
2253
  // advances the floor once and emits those finalized rows exactly once.
2135
- if (agentSurface === null)
2254
+ if (agentSurface === null && outputViewer === null)
2136
2255
  settledRef.current = window.settled;
2137
- const renderedSettled = agentSurface === null ? window.settled : settledRef.current;
2256
+ const renderedSettled = agentSurface === null && outputViewer === null ? window.settled : settledRef.current;
2138
2257
  // One merged vocabulary for the session: this host's own commands plus
2139
2258
  // whatever the kernel's registry reports. Built here so `/help`, the
2140
2259
  // autocomplete and the dispatcher all answer from the same list — three
@@ -2200,6 +2319,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2200
2319
  instructionFiles: session?.instructionFiles ?? [],
2201
2320
  userCommands,
2202
2321
  configDebug: ctx.configDebug ?? null,
2322
+ webSearchSummary: session?.webSearchSummary,
2323
+ columns: terminal.columns,
2324
+ sessionId: scopeRef.current?.sessionId,
2203
2325
  };
2204
2326
  // `/resume`: open the picker with this folder's recent conversations.
2205
2327
  const doResume = useCallback(async () => {
@@ -2244,6 +2366,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2244
2366
  setState('thinking');
2245
2367
  resolve(decision);
2246
2368
  }
2369
+ if (decision.kind === 'approve-all') {
2370
+ for (const pending of permissionQueueRef.current.splice(0))
2371
+ pending.resolve(decision);
2372
+ }
2373
+ const next = permissionQueueRef.current.shift();
2374
+ setQueuedPermissionCount(permissionQueueRef.current.length);
2375
+ if (next) {
2376
+ permissionResolveRef.current = next.resolve;
2377
+ permissionOpenedAtRef.current = Date.now();
2378
+ setPermissionDetailsOpen(!next.permission.summary.complete);
2379
+ setPermission(next.permission);
2380
+ setState('awaiting-permission');
2381
+ }
2247
2382
  }, [setPermissionDetailsOpen, setPermissionReviewOffset]);
2248
2383
  /**
2249
2384
  * Stop the running turn, and hand the screen back in a usable state.
@@ -2264,11 +2399,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2264
2399
  * sends at that prompt, for the same reason.
2265
2400
  */
2266
2401
  const interruptTurn = useCallback(() => {
2402
+ const cancelledSwitch = cancelPendingModelSwitch();
2403
+ for (const pending of permissionQueueRef.current.splice(0)) {
2404
+ pending.resolve({ kind: 'reject', feedback: 'User interrupted.' });
2405
+ }
2406
+ setQueuedPermissionCount(0);
2267
2407
  if (permissionResolveRef.current)
2268
2408
  resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
2269
2409
  const ac = abortRef.current;
2270
- if (!ac)
2271
- return false;
2410
+ if (!ac) {
2411
+ if (cancelledSwitch)
2412
+ discardQueued();
2413
+ return cancelledSwitch;
2414
+ }
2272
2415
  ac.abort(new RunCancelled('user'));
2273
2416
  const activeSessionId = scopeRef.current?.sessionId;
2274
2417
  if (activeSessionId)
@@ -2284,7 +2427,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2284
2427
  clearActiveTools();
2285
2428
  setState('idle');
2286
2429
  return true;
2287
- }, [clearActiveTools, discardQueued, goalActivation, resolvePermission, wakeGoalDriver]);
2430
+ }, [
2431
+ cancelPendingModelSwitch,
2432
+ clearActiveTools,
2433
+ discardQueued,
2434
+ goalActivation,
2435
+ resolvePermission,
2436
+ wakeGoalDriver,
2437
+ ]);
2288
2438
  /**
2289
2439
  * Load the chosen conversation into the transcript and continue in it.
2290
2440
  *
@@ -2357,6 +2507,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2357
2507
  discardQueued();
2358
2508
  goalActivation.clear();
2359
2509
  wakeGoalDriver();
2510
+ cancelPendingModelSwitch();
2360
2511
  conversationGenRef.current += 1;
2361
2512
  resetSubagentActivity();
2362
2513
  session?.resetTaskStore?.();
@@ -2373,6 +2524,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2373
2524
  lastCompletedOutputRef.current = persistedOutput
2374
2525
  ? { text: persistedOutput, provenance: 'persisted' }
2375
2526
  : null;
2527
+ pendingGoalResumeRef.current = conv.id;
2376
2528
  scope.sessionId = conv.id; // new turns now attribute to the resumed session
2377
2529
  conversationMaterializedRef.current = true;
2378
2530
  pushMessage('system', `Resumed: ${conv.title}`);
@@ -2393,6 +2545,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2393
2545
  interruptTurn,
2394
2546
  nextId,
2395
2547
  pushMessage,
2548
+ cancelPendingModelSwitch,
2396
2549
  resetSubagentActivity,
2397
2550
  session,
2398
2551
  resetTranscript,
@@ -2433,6 +2586,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2433
2586
  discardQueued();
2434
2587
  goalActivation.clear();
2435
2588
  wakeGoalDriver();
2589
+ cancelPendingModelSwitch();
2436
2590
  conversationGenRef.current += 1;
2437
2591
  resetSubagentActivity();
2438
2592
  session?.resetTaskStore?.();
@@ -2474,6 +2628,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2474
2628
  goalActivation,
2475
2629
  interruptTurn,
2476
2630
  pushMessage,
2631
+ cancelPendingModelSwitch,
2477
2632
  resetSubagentActivity,
2478
2633
  session,
2479
2634
  resetTranscript,
@@ -2722,6 +2877,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2722
2877
  const readablePrefix = editList
2723
2878
  .slice(0, target.userOrdinal)
2724
2879
  .map((prompt) => prompt.displayText);
2880
+ cancelPendingModelSwitch();
2725
2881
  conversationGenRef.current += 1;
2726
2882
  resetSubagentActivity();
2727
2883
  session?.resetTaskStore?.();
@@ -2765,6 +2921,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2765
2921
  hasUnsettledTurn,
2766
2922
  nextId,
2767
2923
  pushMessage,
2924
+ cancelPendingModelSwitch,
2768
2925
  resetSubagentActivity,
2769
2926
  session,
2770
2927
  resetTranscript,
@@ -2782,6 +2939,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2782
2939
  }
2783
2940
  const summary = buildPermissionSummary(review.text);
2784
2941
  return new Promise((resolve) => {
2942
+ if (permissionResolveRef.current) {
2943
+ permissionQueueRef.current.push({
2944
+ permission: { ...req, review: review.text, summary },
2945
+ resolve,
2946
+ });
2947
+ setQueuedPermissionCount(permissionQueueRef.current.length);
2948
+ return;
2949
+ }
2785
2950
  permissionResolveRef.current = resolve;
2786
2951
  permissionOpenedAtRef.current = Date.now();
2787
2952
  setPermissionReviewOffset(0);
@@ -2860,6 +3025,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2860
3025
  }
2861
3026
  switch (event.kind) {
2862
3027
  case 'delta': {
3028
+ if (event.messageId && st.sourceMessageId !== event.messageId) {
3029
+ if (st.sourceMessageId !== undefined)
3030
+ closeAssistant();
3031
+ st.sourceMessageId = event.messageId;
3032
+ }
2863
3033
  setState('thinking');
2864
3034
  if (event.messageId && event.runId && st.sessionId) {
2865
3035
  lastAssistantMessage.current = {
@@ -2904,11 +3074,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2904
3074
  closeAssistant();
2905
3075
  setThinking(null);
2906
3076
  setState('tool');
3077
+ const waitingAgent = event.toolName === 'wait_for_task' && event.taskId
3078
+ ? subagentsRef.current.find((agent) => agent.taskId === event.taskId &&
3079
+ (event.runId === undefined || agent.workflowId === event.runId))
3080
+ : undefined;
2907
3081
  const tool = {
3082
+ ...(waitingAgent ? { taskId: waitingAgent.taskId } : {}),
2908
3083
  id: event.toolUseId,
2909
3084
  ...(event.runId ? { runId: event.runId } : {}),
2910
3085
  toolName: event.toolName,
2911
- label: formatToolCall(event.toolName, event.summary, event.standalone),
3086
+ activity: event.activity,
3087
+ label: waitingAgent
3088
+ ? `Waiting · ${waitingAgent.description}`
3089
+ : formatToolCall(event.toolName, event.summary, event.standalone),
2912
3090
  startedAt: Date.now(),
2913
3091
  detail: event.detail,
2914
3092
  };
@@ -2952,6 +3130,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2952
3130
  activeToolsRef.current = [...running.slice(0, i), ...running.slice(i + 1)];
2953
3131
  setActiveTools(activeToolsRef.current);
2954
3132
  }
3133
+ // Lifecycle updates own completion rows, even when the model never waits.
3134
+ // Keep unknown waits and errors visible; never hide an unrepresented task.
3135
+ const inspectableWait = done?.taskId && subagentsRef.current.some((agent) => agent.taskId === done.taskId &&
3136
+ (event.runId === undefined || agent.workflowId === event.runId) &&
3137
+ agent.transcript.some((row) => row.kind === 'assistant' && row.text.trim().length > 0));
3138
+ if (!event.isError && inspectableWait && event.toolName === 'wait_for_task') {
3139
+ setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
3140
+ break;
3141
+ }
2955
3142
  const representedSuccessfulAgent = !event.isError &&
2956
3143
  event.toolName.toLowerCase() === 'agent' &&
2957
3144
  subagentsRef.current.some((agent) => agent.toolUseId === event.toolUseId &&
@@ -2964,6 +3151,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2964
3151
  setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
2965
3152
  break;
2966
3153
  }
3154
+ if (!event.isError && done?.activity === 'exploration') {
3155
+ // Preserve the result, including the summary removed from detail by the adapter.
3156
+ const output = event.output !== undefined
3157
+ ? event.output.split('\n')
3158
+ : [...(event.summary ? [event.summary] : []), ...(event.detail ?? [])];
3159
+ pushMessage('tool', done.label, false, '└', output, theme.text.muted, output.length > 0 ? 'ctrl+o output' : undefined, 'exploration');
3160
+ setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
3161
+ break;
3162
+ }
3163
+ const catalogue = !event.isError && event.toolName === 'agent_models' && event.output !== undefined
3164
+ ? modelCatalogueView(event.output) : undefined;
3165
+ if (catalogue !== undefined) {
3166
+ pushMessage('tool', catalogue, false, '✓', event.output?.split('\n'), theme.status.ok, 'ctrl+o details', 'catalogue');
3167
+ setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
3168
+ break;
3169
+ }
2967
3170
  pushMessage('tool', done?.label ?? formatToolCall(event.toolName, event.summary), false, event.isError ? '✗' : '✓', done?.detail, event.isError ? theme.status.error : theme.status.ok, event.durationMs !== undefined
2968
3171
  ? formatElapsed(event.durationMs)
2969
3172
  : done
@@ -3066,12 +3269,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3066
3269
  pushMessage('system', event.text, false, '⇄');
3067
3270
  break;
3068
3271
  case 'capability-warning':
3069
- pushMessage('system', `Capability warning (${event.capability}${event.contentSource === 'tool-result' ? ' tool result' : ''}): ${event.text}`, false, '⚠');
3272
+ pushMessage('system', `Capability warning (${event.capability}${event.contentSource === 'tool-result' ? ' tool result' : ''}): ${event.text}`, false, '!');
3070
3273
  break;
3071
3274
  case 'history-repair':
3072
- pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '⚠');
3275
+ pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '!');
3073
3276
  break;
3074
- case 'done':
3277
+ case 'done': {
3075
3278
  // `run_completed` is not synonymous with success: budgets,
3076
3279
  // cancellation and output guardrails arrive through this event too.
3077
3280
  // Missing remains a normal end for older producers, matching the
@@ -3084,7 +3287,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3084
3287
  outcome: st.completed ? 'completed' : 'stopped',
3085
3288
  };
3086
3289
  closeAssistant();
3290
+ const stopNotice = describeRunStop(event.stopReason);
3291
+ if (stopNotice)
3292
+ pushMessage('system', stopNotice, false, '■');
3087
3293
  break;
3294
+ }
3088
3295
  case 'paused':
3089
3296
  // Paused is a recoverable SDK verdict, not success and not failure.
3090
3297
  // Conversation evidence has no paused member, so it records the
@@ -3101,7 +3308,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3101
3308
  st.outcome = event.message === 'aborted' ? 'cancelled' : 'failed';
3102
3309
  if (event.message !== 'aborted') {
3103
3310
  st.notification = { kind: 'turn-settled', outcome: 'failed' };
3104
- pushMessage('system', describeRunInterruption(event), false, '⚠');
3311
+ pushMessage('system', describeRunInterruption(event), false, '!');
3105
3312
  }
3106
3313
  else
3107
3314
  st.notification = null;
@@ -3109,6 +3316,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3109
3316
  }
3110
3317
  }, [appendToMessage, finalizeMessage, flushStream, pushMessage]);
3111
3318
  const runTurn = useCallback(async (prompt) => {
3319
+ // A passive queue effect can still hold the previous render's session
3320
+ // while a replacement is being committed. Keep FIFO ownership until
3321
+ // the render and the resource-owning session agree.
3322
+ const pendingAtAdmission = pendingModelSwitchRef.current;
3323
+ if (pendingAtAdmission || previousSessionRef.current !== session) {
3324
+ replaceQueued([prompt, ...queuedRef.current]);
3325
+ return;
3326
+ }
3112
3327
  if (!session || !session.hasProvider) {
3113
3328
  pushMessage('system', session?.errorHint ?? 'Agent is not ready yet — give it a moment.');
3114
3329
  return;
@@ -3232,6 +3447,72 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3232
3447
  const turnGeneration = conversationGenRef.current;
3233
3448
  unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
3234
3449
  const stillHere = () => conversationGenRef.current === turnGeneration;
3450
+ const requestModelSwitch = async (request, requestSignal) => {
3451
+ if (!currentProvider ||
3452
+ !stillHere() ||
3453
+ ac.signal.aborted ||
3454
+ requestSignal?.aborted ||
3455
+ appLifetime.signal.aborted ||
3456
+ activeTurnTokenRef.current !== turnToken ||
3457
+ previousSessionRef.current !== session) {
3458
+ return { kind: 'rejected', reason: 'The requesting turn is no longer active.' };
3459
+ }
3460
+ const accepted = pendingModelSwitchRef.current;
3461
+ if (accepted?.turnToken === turnToken &&
3462
+ !accepted.signal.aborted &&
3463
+ accepted.selection?.model === request.model.trim() &&
3464
+ (!request.provider || accepted.selection.id === request.provider.trim())) {
3465
+ return { kind: 'pending', selection: accepted.selection };
3466
+ }
3467
+ // A later request supersedes an earlier lookup even if its model list
3468
+ // resolves first. Cancellation never changes the active provider.
3469
+ cancelPendingModelSwitch();
3470
+ const controller = new AbortController();
3471
+ const pending = {
3472
+ turnToken,
3473
+ generation: turnGeneration,
3474
+ sessionId: scopeRef.current?.sessionId,
3475
+ ownerSession: session,
3476
+ controller,
3477
+ // A tool's lifetime can end before the turn. Once accepted, the
3478
+ // switch belongs to the turn and host, not that completed tool call.
3479
+ signal: AbortSignal.any([ac.signal, appLifetime.signal, controller.signal]),
3480
+ };
3481
+ pendingModelSwitchRef.current = pending;
3482
+ setModelSwitchVersion((version) => version + 1);
3483
+ try {
3484
+ const result = await resolveModelSwitch(request, {
3485
+ currentProvider,
3486
+ detected,
3487
+ describeModels: describeProviderModels,
3488
+ signal: requestSignal ? AbortSignal.any([pending.signal, requestSignal]) : pending.signal,
3489
+ });
3490
+ if (pendingModelSwitchRef.current !== pending ||
3491
+ pending.signal.aborted ||
3492
+ requestSignal?.aborted ||
3493
+ !stillHere() ||
3494
+ activeTurnTokenRef.current !== turnToken) {
3495
+ return { kind: 'rejected', reason: 'The model switch was cancelled or superseded.' };
3496
+ }
3497
+ if (result.kind === 'rejected')
3498
+ return result;
3499
+ pending.selection = result.selection;
3500
+ return { kind: 'pending', selection: result.selection };
3501
+ }
3502
+ catch (error) {
3503
+ return {
3504
+ kind: 'rejected',
3505
+ reason: pending.signal.aborted
3506
+ ? 'The model switch was cancelled.'
3507
+ : `Could not resolve the requested model: ${error instanceof Error ? error.message : String(error)}`,
3508
+ };
3509
+ }
3510
+ finally {
3511
+ if (!pending.selection && pendingModelSwitchRef.current === pending) {
3512
+ cancelPendingModelSwitch();
3513
+ }
3514
+ }
3515
+ };
3235
3516
  let inboxOpen = true;
3236
3517
  const inboxEntries = [];
3237
3518
  const inputWaiters = new Set();
@@ -3368,6 +3649,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3368
3649
  // The mode above decides whether this callback is consulted.
3369
3650
  onPermission: askPermission,
3370
3651
  onQuestion: askQuestion,
3652
+ onModelSwitch: requestModelSwitch,
3371
3653
  inboundMessages: () => inbox.drain(),
3372
3654
  waitForInbound: (signal) => inbox.waitForInbound(signal),
3373
3655
  extraSystem: [composeSkillsPrompt(activeSkills), drainIdleJobNotices(), drainOperatorShell()]
@@ -3480,6 +3762,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3480
3762
  abortRef.current = null;
3481
3763
  if (ownsTurn)
3482
3764
  activeTurnTokenRef.current = null;
3765
+ permissionResolveRef.current?.({ kind: 'reject', feedback: 'The owning turn ended.' });
3766
+ for (const pending of permissionQueueRef.current.splice(0))
3767
+ pending.resolve({ kind: 'reject', feedback: 'The owning turn ended.' });
3768
+ setQueuedPermissionCount(0);
3483
3769
  permissionResolveRef.current = null;
3484
3770
  permissionOpenedAtRef.current = null;
3485
3771
  setPermission(null);
@@ -3549,15 +3835,76 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3549
3835
  // operation that sees no current-generation entries can now await that
3550
3836
  // tail without a turn appearing behind its read later.
3551
3837
  unsettledTurnGenerationsRef.current.delete(turnToken);
3838
+ const pending = pendingModelSwitchRef.current;
3839
+ if (pending?.turnToken === turnToken && pending.selection) {
3840
+ const selection = pending.selection;
3841
+ const ownsSwitch = () => pendingModelSwitchRef.current === pending &&
3842
+ conversationGenRef.current === pending.generation &&
3843
+ scopeRef.current?.sessionId === pending.sessionId &&
3844
+ previousSessionRef.current === pending.ownerSession &&
3845
+ !pending.signal.aborted;
3846
+ const beforePublish = () => {
3847
+ if (!ownsSwitch() ||
3848
+ conversationMutationRef.current ||
3849
+ hasUnsettledTurn(pending.generation) ||
3850
+ abortRef.current ||
3851
+ compactingRef.current ||
3852
+ exportingRef.current) {
3853
+ throw new Error('The active conversation changed before the model switch could complete.');
3854
+ }
3855
+ if (pending.ownerSession.subagents
3856
+ ?.getSnapshot()
3857
+ .some((agent) => agent.status === 'starting' ||
3858
+ agent.status === 'queued' ||
3859
+ agent.status === 'working')) {
3860
+ throw new Error('Delegated agents are still active. Wait for them to finish before switching models.');
3861
+ }
3862
+ if (pending.ownerSession.jobs?.().some((job) => job.status === 'running')) {
3863
+ throw new Error('Background jobs are still running. Wait for them to finish before switching models.');
3864
+ }
3865
+ };
3866
+ try {
3867
+ await persistenceTailRef.current;
3868
+ beforePublish();
3869
+ await hydrateSession(selectPrimaryProvider(savedPrefsRef.current, selection), detected, {
3870
+ signal: pending.signal,
3871
+ persistSelection: false,
3872
+ beforePublish,
3873
+ });
3874
+ if (pendingModelSwitchRef.current === pending &&
3875
+ !pending.signal.aborted &&
3876
+ previousSessionRef.current !== pending.ownerSession) {
3877
+ pushMessage('system', `Switched to ${selection.id} · ${selection.model} for this conversation.`);
3878
+ }
3879
+ }
3880
+ catch (error) {
3881
+ if (ownsSwitch()) {
3882
+ pushMessage('system', `Model switch failed: ${error instanceof Error ? error.message : String(error)} The current model is unchanged.`);
3883
+ }
3884
+ }
3885
+ finally {
3886
+ if (pendingModelSwitchRef.current === pending) {
3887
+ pendingModelSwitchRef.current = null;
3888
+ setModelSwitchVersion((version) => version + 1);
3889
+ wakeGoalDriver();
3890
+ }
3891
+ }
3892
+ }
3552
3893
  }
3553
3894
  }, [
3554
3895
  activeSkills,
3896
+ appLifetime.signal,
3555
3897
  applyEvent,
3898
+ cancelPendingModelSwitch,
3556
3899
  clearActiveTools,
3900
+ currentProvider,
3557
3901
  ctx.cwd,
3902
+ detected,
3558
3903
  finalizeMessage,
3559
3904
  flushStream,
3560
3905
  goalActivation,
3906
+ hasUnsettledTurn,
3907
+ hydrateSession,
3561
3908
  materializeConversation,
3562
3909
  onPermission,
3563
3910
  pushMessage,
@@ -3567,6 +3914,109 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3567
3914
  setQueuePause,
3568
3915
  wakeGoalDriver,
3569
3916
  ]);
3917
+ const selectModelIntent = useCallback(async (query) => {
3918
+ if (!session ||
3919
+ !currentProvider ||
3920
+ state !== 'idle' ||
3921
+ phase !== 'ready' ||
3922
+ abortRef.current ||
3923
+ hasUnsettledTurn() ||
3924
+ pendingModelSwitchRef.current ||
3925
+ queuedRef.current.length > 0 ||
3926
+ permissionResolveRef.current ||
3927
+ compactingRef.current ||
3928
+ conversationMutationRef.current ||
3929
+ exportingRef.current ||
3930
+ goalDriveInFlightRef.current) {
3931
+ pushMessage('system', 'Model selection is unavailable until the active work and queue reach a stable boundary.');
3932
+ return;
3933
+ }
3934
+ const controller = new AbortController();
3935
+ const pending = {
3936
+ turnToken: {},
3937
+ generation: conversationGenRef.current,
3938
+ sessionId: scopeRef.current?.sessionId,
3939
+ ownerSession: session,
3940
+ controller,
3941
+ signal: AbortSignal.any([appLifetime.signal, controller.signal]),
3942
+ };
3943
+ pendingModelSwitchRef.current = pending;
3944
+ setModelSwitchVersion((version) => version + 1);
3945
+ const owns = () => pendingModelSwitchRef.current === pending &&
3946
+ !pending.signal.aborted &&
3947
+ conversationGenRef.current === pending.generation &&
3948
+ scopeRef.current?.sessionId === pending.sessionId &&
3949
+ previousSessionRef.current === session;
3950
+ const beforePublish = () => {
3951
+ if (!owns() ||
3952
+ abortRef.current ||
3953
+ hasUnsettledTurn() ||
3954
+ conversationMutationRef.current ||
3955
+ compactingRef.current ||
3956
+ exportingRef.current ||
3957
+ permissionResolveRef.current ||
3958
+ session.jobs?.().some((job) => job.status === 'running') ||
3959
+ session.subagents
3960
+ ?.getSnapshot()
3961
+ .some((agent) => ['starting', 'queued', 'working'].includes(agent.status))) {
3962
+ throw new Error('Active work or the conversation changed before model selection could complete.');
3963
+ }
3964
+ };
3965
+ try {
3966
+ beforePublish();
3967
+ pushMessage('system', `Model requested: ${query}. Checking available catalogues…`);
3968
+ const resolved = await resolveModelSelectionIntent(query, {
3969
+ currentProvider,
3970
+ detected,
3971
+ describeModels: describeProviderModels,
3972
+ signal: pending.signal,
3973
+ });
3974
+ if (!owns())
3975
+ return;
3976
+ if (resolved.kind === 'rejected') {
3977
+ pushMessage('system', resolved.reason +
3978
+ (resolved.choices?.length
3979
+ ? ` Choices: ${resolved.choices.map((choice) => `${choice.provider}/${choice.model}`).join(', ')}.`
3980
+ : ''));
3981
+ return;
3982
+ }
3983
+ pending.selection = resolved.selection;
3984
+ await persistenceTailRef.current;
3985
+ beforePublish();
3986
+ await hydrateSession(selectPrimaryProvider(savedPrefsRef.current, resolved.selection), detected, {
3987
+ signal: pending.signal,
3988
+ persistSelection: false,
3989
+ beforePublish,
3990
+ });
3991
+ if (pendingModelSwitchRef.current === pending &&
3992
+ !pending.signal.aborted &&
3993
+ previousSessionRef.current !== session) {
3994
+ pushMessage('system', `Switched to ${resolved.selection.id} · ${resolved.selection.model} for this conversation.`);
3995
+ }
3996
+ }
3997
+ catch (error) {
3998
+ if (owns())
3999
+ pushMessage('system', `Model selection failed: ${error instanceof Error ? error.message : String(error)} The current model is unchanged.`);
4000
+ }
4001
+ finally {
4002
+ if (pendingModelSwitchRef.current === pending) {
4003
+ pendingModelSwitchRef.current = null;
4004
+ setModelSwitchVersion((version) => version + 1);
4005
+ wakeGoalDriver();
4006
+ }
4007
+ }
4008
+ }, [
4009
+ session,
4010
+ currentProvider,
4011
+ state,
4012
+ phase,
4013
+ hasUnsettledTurn,
4014
+ appLifetime.signal,
4015
+ pushMessage,
4016
+ detected,
4017
+ hydrateSession,
4018
+ wakeGoalDriver,
4019
+ ]);
3570
4020
  const handleSubmit = useCallback((value, attachments, mode = 'submit') => {
3571
4021
  if (goalCommandInFlightRef.current) {
3572
4022
  pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
@@ -3590,14 +4040,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3590
4040
  return;
3591
4041
  }
3592
4042
  setHistory((prev) => [...prev, value]);
4043
+ const selectionIntent = !attachments?.length ? parseModelSelectionIntent(value) : undefined;
4044
+ if (selectionIntent) {
4045
+ void selectModelIntent(selectionIntent.query);
4046
+ return;
4047
+ }
3593
4048
  // `#` remembers, `!` runs — neither is a prompt. Both are the
3594
4049
  // operator acting directly, the way other coding agents spell it,
3595
4050
  // and both leave a row the model reads on its next turn.
3596
4051
  if (value.startsWith('#') && value.slice(1).trim().length > 0) {
3597
4052
  const note = value.slice(1).trim();
3598
4053
  try {
3599
- const path = appendMemory(note, { scope: 'project', cwd: ctx.cwd });
3600
- pushMessage('system', `Remembered for this project (${path}): ${note}`);
4054
+ const saved = appendMemoryWithStatus(note, { scope: 'project', cwd: ctx.cwd });
4055
+ pushMessage('system', renderMemorySaveResult(saved, note));
3601
4056
  }
3602
4057
  catch (err) {
3603
4058
  pushMessage('system', `Could not save memory: ${err instanceof Error ? err.message : String(err)}`);
@@ -3634,7 +4089,16 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3634
4089
  if (slash) {
3635
4090
  switch (slash.kind) {
3636
4091
  case 'message':
3637
- pushMessage(slash.role, slash.content);
4092
+ if (slash.statusRows) {
4093
+ setMessages((previous) => [...previous, {
4094
+ id: nextId(),
4095
+ role: slash.role,
4096
+ content: slash.content,
4097
+ statusRows: slash.statusRows,
4098
+ }]);
4099
+ }
4100
+ else
4101
+ pushMessage(slash.role, slash.content);
3638
4102
  return;
3639
4103
  case 'command-picker': {
3640
4104
  if (slash.commands.length === 0) {
@@ -3664,6 +4128,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3664
4128
  }
3665
4129
  return;
3666
4130
  }
4131
+ case 'provider-setup':
4132
+ if (state !== 'idle' || permission || choicePickerRef.current) {
4133
+ pushMessage('system', 'Provider setup is available once the active turn and prompts finish.');
4134
+ return;
4135
+ }
4136
+ setProviderSetup(true);
4137
+ return;
3667
4138
  case 'settings-picker': {
3668
4139
  const commands = [
3669
4140
  {
@@ -3685,16 +4156,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3685
4156
  description: `${permissionModeLabel(effectivePermissionMode(permissionModeRef.current, session?.approvalLatched() ?? false))} · this session.`,
3686
4157
  },
3687
4158
  {
3688
- name: 'status config',
3689
- label: 'Configuration',
4159
+ name: 'status',
4160
+ label: 'Web & session',
4161
+ description: `${session?.webSearchSummary ?? 'Not resolved'} · view tools, workspace and usage.`,
4162
+ },
4163
+ { name: 'setup', label: 'Provider setup', description: 'Check installed CLIs and access; connect or install without leaving Namzu.' },
4164
+ {
4165
+ name: 'config sources',
4166
+ label: 'Setting sources',
3690
4167
  description: 'Show which configuration files and overrides are in use.',
3691
4168
  },
3692
4169
  ];
3693
4170
  setSelectedChoice(0);
3694
4171
  setChoicePicker({
3695
4172
  kind: 'command',
3696
- title: 'Settings',
3697
- notice: 'Select a setting to view or change it.',
4173
+ title: 'Configuration',
4174
+ notice: 'Model, reasoning and permission changes apply to this session.',
3698
4175
  values: commands,
3699
4176
  options: commands.map((command) => ({
3700
4177
  label: command.label,
@@ -3866,6 +4343,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3866
4343
  return;
3867
4344
  case 'repick':
3868
4345
  if (state !== 'idle' ||
4346
+ pendingModelSwitchRef.current ||
3869
4347
  abortRef.current !== null ||
3870
4348
  hasUnsettledTurn() ||
3871
4349
  (queuedRef.current.length > 0 && queuePauseRef.current === null) ||
@@ -3934,17 +4412,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3934
4412
  const values = [undefined, ...levels];
3935
4413
  const current = reasoningEffortRef.current;
3936
4414
  setSelectedChoice(Math.max(0, values.findIndex((value) => value === current)));
3937
- setChoicePicker({
3938
- kind: 'reasoning-effort',
3939
- title: `Select Reasoning Level for ${session.modelSummary ?? 'current model'}`,
3940
- values,
3941
- options: values.map((effort) => ({
3942
- label: effort ?? 'default',
3943
- description: reasoningEffortDescription(effort),
3944
- current: effort === current,
3945
- default: effort === undefined,
3946
- })),
3947
- });
4415
+ setChoicePicker(reasoningEffortPicker(session, current) ?? null);
3948
4416
  return;
3949
4417
  }
3950
4418
  case 'login':
@@ -3984,8 +4452,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3984
4452
  }
3985
4453
  case 'remember':
3986
4454
  try {
3987
- const path = appendMemory(slash.text, { scope: slash.scope, cwd: ctx.cwd });
3988
- pushMessage('system', `Remembered ${slash.scope === 'user' ? 'for every project' : 'for this project'} (${path}): ${slash.text}`);
4455
+ const saved = appendMemoryWithStatus(slash.text, { scope: slash.scope, cwd: ctx.cwd });
4456
+ pushMessage('system', renderMemorySaveResult(saved, slash.text));
3989
4457
  }
3990
4458
  catch (err) {
3991
4459
  pushMessage('system', `Could not save memory: ${err instanceof Error ? err.message : String(err)}`);
@@ -4011,7 +4479,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4011
4479
  // Dropping it silently would leave someone wondering where a
4012
4480
  // file they can see on disk went.
4013
4481
  s.problem
4014
- ? `⚠ ${s.name} — ${s.problem}`
4482
+ ? `! ${s.name} — ${s.problem}`
4015
4483
  : `${activeNames.has(s.name) ? '● ' : '○ '}${s.name} — ${s.description}`);
4016
4484
  pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
4017
4485
  return;
@@ -4425,6 +4893,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4425
4893
  resetTranscript,
4426
4894
  runConversationExport,
4427
4895
  session,
4896
+ selectModelIntent,
4428
4897
  setChoicePicker,
4429
4898
  setCopyPicker,
4430
4899
  setReasoningEffort,
@@ -4462,6 +4931,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4462
4931
  return;
4463
4932
  }
4464
4933
  setGoalStatus(goal);
4934
+ if (pendingGoalResumeRef.current === sessionId && phase === 'ready' && !choicePickerRef.current) {
4935
+ pendingGoalResumeRef.current = null;
4936
+ if (goal && (goal.phase === 'active' || goal.phase === 'paused')) {
4937
+ setSelectedChoice(0);
4938
+ setChoicePicker({ kind: 'resume-goal', title: 'Continue the saved goal?',
4939
+ notice: goal.objective, sessionId, values: ['resume', 'later'],
4940
+ options: [
4941
+ { label: 'Resume goal', description: 'Continue automatic work toward this objective.' },
4942
+ { label: 'Not now', description: 'Keep automatic work stopped; resume later with /goal resume.' },
4943
+ ],
4944
+ });
4945
+ }
4946
+ }
4465
4947
  })
4466
4948
  .catch(() => {
4467
4949
  if (!disposed && conversationGenRef.current === generation)
@@ -4470,7 +4952,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4470
4952
  return () => {
4471
4953
  disposed = true;
4472
4954
  };
4473
- }, [goalDriveVersion, phase, session]);
4955
+ }, [goalDriveVersion, phase, session, setChoicePicker]);
4474
4956
  // Admit automatic work only at a whole-App durable boundary. This effect
4475
4957
  // reserves; it never starts a turn itself. The one queue pump below remains
4476
4958
  // the sole starter, which is what keeps a human prompt that arrives during
@@ -4488,6 +4970,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4488
4970
  queuedRef.current.length > 0 ||
4489
4971
  abortRef.current ||
4490
4972
  hasUnsettledTurn() ||
4973
+ pendingModelSwitchRef.current ||
4491
4974
  conversationMutationRef.current ||
4492
4975
  exportingRef.current ||
4493
4976
  compactingRef.current ||
@@ -4515,6 +4998,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4515
4998
  state !== 'idle' ||
4516
4999
  abortRef.current ||
4517
5000
  hasUnsettledTurn(generation) ||
5001
+ pendingModelSwitchRef.current ||
4518
5002
  conversationMutationRef.current ||
4519
5003
  exportingRef.current ||
4520
5004
  compactingRef.current ||
@@ -4540,6 +5024,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4540
5024
  // or goal command is different: it revoked this scheduling boundary.
4541
5025
  if (conversationGenRef.current !== generation ||
4542
5026
  scopeRef.current?.sessionId !== sessionId ||
5027
+ pendingModelSwitchRef.current ||
4543
5028
  conversationMutationRef.current ||
4544
5029
  goalCommandInFlightRef.current ||
4545
5030
  !goalActivation.isArmed(sessionId, armed)) {
@@ -4572,6 +5057,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4572
5057
  enqueueQueued,
4573
5058
  goalActivation,
4574
5059
  goalDriveVersion,
5060
+ modelSwitchVersion,
4575
5061
  hasUnsettledTurn,
4576
5062
  phase,
4577
5063
  pushMessage,
@@ -4592,6 +5078,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4592
5078
  queuePauseRef.current !== null ||
4593
5079
  abortRef.current ||
4594
5080
  hasUnsettledTurn() ||
5081
+ pendingModelSwitchRef.current ||
4595
5082
  conversationMutationRef.current ||
4596
5083
  exportingRef.current ||
4597
5084
  compactingRef.current ||
@@ -4611,6 +5098,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4611
5098
  state,
4612
5099
  phase,
4613
5100
  queued,
5101
+ modelSwitchVersion,
4614
5102
  queuePause,
4615
5103
  textPrompt,
4616
5104
  choicePicker,
@@ -4635,7 +5123,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4635
5123
  }, [phase, ctx.version, pushMessage]);
4636
5124
  const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal, options = {}) => {
4637
5125
  try {
4638
- await hydrateSession(prefs, detectedNow, signal, options.persistSelection);
5126
+ await hydrateSession(prefs, detectedNow, {
5127
+ signal,
5128
+ persistSelection: options.persistSelection,
5129
+ announce: true,
5130
+ chooseReasoningEffort: options.chooseReasoningEffort,
5131
+ });
4639
5132
  }
4640
5133
  catch (err) {
4641
5134
  // A superseded choice no longer owns even its failure message. Its
@@ -4687,6 +5180,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4687
5180
  // Only a real refusal broadens back to the general, sign-in-capable picker.
4688
5181
  void hydrateFromPicker(prefs, detected, signal, {
4689
5182
  revealAllOnFailure: true,
5183
+ chooseReasoningEffort: selection.model !== undefined,
4690
5184
  persistSelection: detected.find((provider) => provider.entry.id === selection.provider)?.source.kind !==
4691
5185
  'session',
4692
5186
  });
@@ -4720,6 +5214,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4720
5214
  exitWithSummary();
4721
5215
  }, [session, exitWithSummary]);
4722
5216
  useInput((input, key) => {
5217
+ if (outputViewer !== null)
5218
+ return;
4723
5219
  // Startup has refused admission, so there is no draft or active turn to
4724
5220
  // protect with the ready screen's two-press exit ladder.
4725
5221
  if (phase === 'unhealthy') {
@@ -4840,9 +5336,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4840
5336
  if (permission && (key.pageUp || key.pageDown || key.home || key.end)) {
4841
5337
  const source = permissionDetailsOpenRef.current
4842
5338
  ? permission.review
4843
- : permission.summary.text;
5339
+ : (permission.summary.compactText ?? permission.summary.text);
4844
5340
  const count = permissionReviewRows(source, terminal.columns).length;
4845
- const pageRows = permissionReviewPageRows(terminal.rows);
5341
+ const pageRows = Math.max(1, permissionReviewPageRows(terminal.rows) - (permission.runId ? 1 : 0));
4846
5342
  const maxOffset = Math.max(0, count - pageRows);
4847
5343
  const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
4848
5344
  const next = key.home
@@ -4858,8 +5354,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4858
5354
  // Refusing is never deferred or gated — it is the direction a
4859
5355
  // mistake is recoverable in, so it answers on the first press.
4860
5356
  if (key.ctrl && input === 'c') {
4861
- resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
4862
- abortRef.current?.abort(new RunCancelled('user'));
5357
+ interruptTurn();
4863
5358
  return;
4864
5359
  }
4865
5360
  if (ch === 'n' || key.escape) {
@@ -4905,7 +5400,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4905
5400
  return;
4906
5401
  const agents = subagentsRef.current;
4907
5402
  if (agentView.kind === 'cockpit') {
4908
- const phases = agentPhases(agents);
5403
+ const workflows = agentWorkflows(agents);
5404
+ const workflowIndex = Math.max(0, workflows.findIndex((workflow) => workflow.phases.some((phase) => phase.id === agentView.selectedPhaseId)));
5405
+ const workflow = workflows[workflowIndex];
5406
+ const phases = workflow?.phases ?? [];
4909
5407
  const phaseIndex = Math.max(0, phases.findIndex((phase) => phase.id === agentView.selectedPhaseId));
4910
5408
  const phase = phases[phaseIndex];
4911
5409
  const phaseAgents = phase?.agents ?? [];
@@ -4915,11 +5413,27 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4915
5413
  setAgentSurface(null);
4916
5414
  return;
4917
5415
  }
4918
- if (key.escape || (key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
5416
+ if ((key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
4919
5417
  setAgentSurface(null);
4920
5418
  return;
4921
5419
  }
5420
+ if (key.escape) {
5421
+ setAgentSurface(workflows.length > 1 && agentView.focus !== 'workflows'
5422
+ ? { ...agentView, focus: 'workflows' }
5423
+ : null);
5424
+ return;
5425
+ }
4922
5426
  if (key.leftArrow || key.rightArrow || key.tab) {
5427
+ if (agentView.focus === 'workflows') {
5428
+ if (!key.leftArrow) {
5429
+ setAgentSurface({ ...agentView, focus: phases.length > 1 ? 'phases' : 'agents' });
5430
+ }
5431
+ return;
5432
+ }
5433
+ if (key.leftArrow && agentView.focus === 'phases' && workflows.length > 1) {
5434
+ setAgentSurface({ ...agentView, focus: 'workflows' });
5435
+ return;
5436
+ }
4923
5437
  const focus = key.leftArrow
4924
5438
  ? 'phases'
4925
5439
  : key.rightArrow
@@ -4931,6 +5445,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4931
5445
  return;
4932
5446
  }
4933
5447
  if (key.return) {
5448
+ if (agentView.focus === 'workflows') {
5449
+ setAgentSurface({ ...agentView, focus: phases.length > 1 ? 'phases' : 'agents' });
5450
+ return;
5451
+ }
4934
5452
  if (agentView.focus === 'phases') {
4935
5453
  setAgentSurface({ ...agentView, focus: 'agents' });
4936
5454
  return;
@@ -4956,7 +5474,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4956
5474
  : key.upArrow
4957
5475
  ? 'previous'
4958
5476
  : 'next';
4959
- if (agentView.focus === 'phases') {
5477
+ if (agentView.focus === 'workflows') {
5478
+ const next = moveSelection(workflowIndex, workflows.length, movement, agentWorkflowPageSize(terminal.rows));
5479
+ const targetPhase = workflows[next]?.phases[0];
5480
+ const targetAgent = targetPhase?.agents[0];
5481
+ if (targetPhase && targetAgent) {
5482
+ setAgentSurface({
5483
+ ...agentView,
5484
+ selectedPhaseId: targetPhase.id,
5485
+ selectedId: targetAgent.viewId,
5486
+ });
5487
+ }
5488
+ }
5489
+ else if (agentView.focus === 'phases') {
4960
5490
  const next = moveSelection(phaseIndex, phases.length, movement, agentPhasePageSize(terminal.rows, agentCockpitIsWide(terminal.columns)));
4961
5491
  const targetPhase = phases[next];
4962
5492
  const targetAgent = targetPhase?.agents[0];
@@ -5139,69 +5669,32 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5139
5669
  }
5140
5670
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
5141
5671
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
5142
- if (key.escape && abortRef.current) {
5672
+ if (key.escape && (abortRef.current || pendingModelSwitchRef.current)) {
5143
5673
  interruptTurn();
5144
5674
  pushMessage('system', 'Interrupted.');
5145
5675
  return;
5146
5676
  }
5147
- // Ctrl+O expands the collapsed bodies that are still redrawable.
5148
- //
5149
- // It was advertised — on every collapsed body — as toggling full
5150
- // expansion for everything, and in that use it did nothing: finalized
5151
- // rows went through `<Static>`, which renders `items.slice(index)` and
5152
- // calls the render function only for items it has not emitted yet, so
5153
- // output already on screen was beyond its reach. Measured, with a
5154
- // twelve-line body up: pressing it produced one further frame whose
5155
- // transcript region was byte-identical to the one before.
5156
- //
5157
- // The rows at the end of the transcript are now drawn live rather than
5158
- // printed once, so for those the key does what it always claimed —
5159
- // flipping the flag re-renders the row where it already is. It reaches
5160
- // exactly as far back as the window does, and says so when that is
5161
- // nowhere. It does not quietly fall back to appending a copy: `/expand`
5162
- // is that, deliberately and visibly, and a key that sometimes redraws
5163
- // in place and sometimes prints a second copy further down would be two
5164
- // commands wearing one name.
5677
+ // Small expansions stay in place. Older or oversized bodies open a viewer.
5165
5678
  if (key.ctrl && input === 'o') {
5166
- // Taken outside the updater so both branches use the same one.
5167
- const id = nextId();
5168
- setMessages((prev) => {
5169
- const live = prev.filter((m) => !m.pending).slice(settledRef.current);
5170
- const collapsible = live.filter((m) => willCollapse(m.detail));
5171
- if (collapsible.length === 0) {
5172
- // Nothing redrawable is collapsed, so this reprints the most recent
5173
- // collapsed body as a new row — the rows above have been printed
5174
- // to the terminal's own scrollback, which cannot be rewritten.
5175
- const blocks = prev.filter((m) => m.detailRef !== undefined);
5176
- const block = blocks[blocks.length - 1];
5177
- if (!block) {
5178
- return [
5179
- ...prev,
5180
- {
5181
- id,
5182
- role: 'system',
5183
- content: 'Nothing to expand yet. Tool output longer than six lines collapses with a "… +N lines · ctrl+o" hint; Ctrl+O opens it in place while it is still on screen, and reprints the most recent one in full once it has scrolled away.',
5184
- },
5185
- ];
5186
- }
5187
- return [
5188
- ...prev,
5189
- {
5190
- id,
5191
- role: 'system',
5192
- content: `${block.content} — in full (${block.detail?.length ?? 0} lines)`,
5193
- glyph: '⤢',
5194
- detail: block.detail,
5195
- detailExpanded: true,
5196
- },
5197
- ];
5198
- }
5199
- // Expanded unless they are all open already, in which case this
5200
- // closes them again — the toggle it was always described as.
5201
- const expanding = collapsible.some((m) => m.detailExpanded !== true);
5202
- const ids = new Set(collapsible.map((m) => m.id));
5203
- return prev.map((m) => (ids.has(m.id) ? { ...m, detailExpanded: expanding } : m));
5204
- });
5679
+ const live = messages.filter((m) => !m.pending).slice(settledRef.current);
5680
+ const collapsible = live.filter((m) => (m.activity && (m.detail?.length ?? 0) > 0) || willCollapse(m.detail));
5681
+ const blocks = messages.filter((m) => m.detailRef !== undefined && (m.detail?.length ?? 0) > 0);
5682
+ const block = collapsible.at(-1) ?? blocks.at(-1);
5683
+ if (!block) {
5684
+ pushMessage('system', 'Nothing to expand yet. Ctrl+O opens retained tool output when available.');
5685
+ return;
5686
+ }
5687
+ const expanding = collapsible.some((m) => m.detailExpanded !== true);
5688
+ const ids = new Set(collapsible.map((m) => m.id));
5689
+ const proposed = messages.map((m) => ids.has(m.id) ? { ...m, detailExpanded: expanding } : m);
5690
+ const projected = liveWindow({ messages: proposed.filter((m) => !m.pending), rows: terminal.rows,
5691
+ columns: terminal.columns, furnitureRows: LIVE_FURNITURE_ROWS + taskFurniture + toolFurniture,
5692
+ settled: settledRef.current, raw: rawOutput });
5693
+ if (collapsible.length === 0 || (expanding && projected.settled > settledRef.current)) {
5694
+ setOutputViewer(block);
5695
+ }
5696
+ else
5697
+ setMessages(proposed);
5205
5698
  return;
5206
5699
  }
5207
5700
  if (key.ctrl && input === 'c') {
@@ -5226,7 +5719,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5226
5719
  // Active on every phase. It was gated off during the picker, which is what
5227
5720
  // left that screen without a usable exit; the picker branch above returns
5228
5721
  // immediately, so the Picker still owns everything except Ctrl+C.
5229
- { isActive: externalEditorRequest === null });
5722
+ { isActive: externalEditorRequest === null && !providerSetup });
5230
5723
  // Background is left natural — we inherit the terminal's own background
5231
5724
  // and only theme the foreground. Forcing
5232
5725
  // a filled bg left mismatched patches around bordered areas, so we don't.
@@ -5243,10 +5736,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5243
5736
  !(tool.runId
5244
5737
  ? representedSubagentTools.has(JSON.stringify([tool.runId, tool.id]))
5245
5738
  : representedUnscopedSubagentToolUseIds.has(tool.id)));
5739
+ const outputBlocks = outputViewer
5740
+ ? messages.filter((m) => m.detailRef !== undefined && (m.detail?.length ?? 0) > 0)
5741
+ : [];
5742
+ const outputIndex = outputBlocks.findIndex((m) => m.id === outputViewer?.id);
5246
5743
  const selectedSubagent = agentSurface
5247
5744
  ? subagents.find((agent) => agent.viewId === agentSurface.selectedId)
5248
5745
  : undefined;
5249
- const lifecycleOwnsViewport = phase === 'trust' ||
5746
+ const lifecycleOwnsViewport = outputViewer !== null ||
5747
+ phase === 'trust' ||
5250
5748
  phase === 'resume' ||
5251
5749
  phase === 'edit' ||
5252
5750
  phase === 'picker' ||
@@ -5260,7 +5758,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5260
5758
  choicePicker === null &&
5261
5759
  copyPicker === null &&
5262
5760
  agentSurface === null
5263
- ? goalStatusLabel(goalStatus)
5761
+ ? goalStatusLabel(goalStatus, goalStatus ? goalActivation.isArmed(goalStatus.sessionId, goalStatus) : false)
5264
5762
  : null;
5265
5763
  const statusHint = conversationMutation === 'fork'
5266
5764
  ? 'forking conversation — input is paused'
@@ -5275,9 +5773,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5275
5773
  : permission
5276
5774
  ? hintForPhase(phase, state, session?.hasProvider === true)
5277
5775
  : agentSurface?.kind === 'cockpit'
5278
- ? agentSurface.focus === 'phases'
5279
- ? 'agent phases — enter agents · esc return'
5280
- : 'agents — enter inspect · left phases · esc return'
5776
+ ? agentSurface.focus === 'workflows'
5777
+ ? 'workflows — enter select · esc return'
5778
+ : agentSurface.focus === 'phases'
5779
+ ? 'agent phases — enter agents · esc return'
5780
+ : 'agents — enter inspect · left phases · esc return'
5281
5781
  : agentSurface?.kind === 'transcript'
5282
5782
  ? 'observing agent — esc agents · q parent'
5283
5783
  : textPrompt
@@ -5291,8 +5791,21 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5291
5791
  : goalStatus && phase === 'ready' && state === 'idle'
5292
5792
  ? undefined
5293
5793
  : hintForPhase(phase, state, session?.hasProvider === true, composerHasDraft);
5794
+ const permissionOwner = permission?.runId
5795
+ ? subagents.find((agent) => agent.runId === permission.runId)
5796
+ : undefined;
5797
+ const permissionSourceLabel = permission?.runId
5798
+ ? permissionOwner ? `Agent: ${permissionOwner.description}` : `Run: ${permission.runId}`
5799
+ : undefined;
5294
5800
  const displayedPermissionMode = effectivePermissionMode(permissionMode, session?.approvalLatched() ?? false);
5295
- return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: transcriptOwned ? renderedSettled : 0, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, showLive: !lifecycleOwnsViewport, header: transcriptOwned ? (_jsx(BrandHeader, { version: ctx.version, provider: session?.providerSummary, model: session?.modelSummary, permissionMode: displayedPermissionMode, cwd: ctx.cwd })) : undefined }) }), phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'unhealthy' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.status.error, bold: true, children: "Startup stopped" }), _jsx(Text, { color: theme.text.secondary, children: "Resolve the error above, then restart Namzu." })] })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [agentSurface === null ? (_jsx(LiveActivity, { compact: compactWork, activeTools: visibleActiveTools, working: state === 'thinking' || state === 'tool', interruptible: abortRef.current !== null, animate: stdout.isTTY === true, thinking: thinking })) : null, agentSurface === null && permission === null ? (_jsx(TaskList, { tasks: tasks, compact: compactWork })) : null, permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, summary: permission.summary, detailsOpen: permissionDetailsOpen, reviewOffset: permissionReviewOffset, choice: permissionChoice, columns: terminal.columns, rows: terminal.rows })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null || agentSurface !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && agentSurface === null && choicePicker ? (_jsx(ChoicePicker, { columns: Math.max(1, (terminal.columns ?? 80) - 2), title: choicePicker.title, notice: choicePicker.notice, options: filterChoiceOptions(choicePicker.options, choiceQuery), query: choicePickerSearchable(choicePicker) ? choiceQuery : undefined, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && agentSurface === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { working: state === 'thinking' || state === 'tool' || visibleActiveTools.length > 0, focus: phase === 'ready' &&
5801
+ return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: transcriptOwned ? renderedSettled : 0, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, showLive: !lifecycleOwnsViewport, header: transcriptOwned ? (_jsx(BrandHeader, { version: ctx.version, permissionMode: displayedPermissionMode })) : undefined }) }), providerSetup ? (_jsx(ProviderSetup, { cwd: ctx.cwd, onClose: () => setProviderSetup(false), onConnect: () => {
5802
+ setProviderSetup(false);
5803
+ setPickerInitialView('providers');
5804
+ setPickerDetected(null);
5805
+ setPickerSelectionKind('provider-and-model');
5806
+ setPhase('picker');
5807
+ void discoverProviders({ skipProbes: true }).then(setPickerDetected).catch((error) => setPickerNotice(String(error)));
5808
+ } })) : phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'unhealthy' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.status.error, bold: true, children: "Startup stopped" }), _jsx(Text, { color: theme.text.secondary, children: "Resolve the error above, then restart Namzu." })] })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onSetup: () => setProviderSetup(true), onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [agentSurface === null && outputViewer === null ? (_jsx(LiveActivity, { compact: compactWork, activeTools: visibleActiveTools, working: state === 'thinking' || state === 'tool', interruptible: abortRef.current !== null, animate: stdout.isTTY === true, thinking: thinking })) : null, agentSurface === null && outputViewer === null && permission === null ? (_jsx(TaskList, { tasks: tasks, compact: compactWork })) : null, permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, summary: permission.summary, detailsOpen: permissionDetailsOpen, reviewOffset: permissionReviewOffset, choice: permissionChoice, queuedCount: queuedPermissionCount, sourceLabel: permissionSourceLabel, columns: terminal.columns, rows: terminal.rows })) : null, textPrompt ? (_jsx(TextPrompt, { columns: Math.max(1, (terminal.columns ?? 80) - 2), title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null || (agentSurface !== null || outputViewer !== null), onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && agentSurface === null && outputViewer === null && choicePicker ? (_jsx(ChoicePicker, { columns: Math.max(1, (terminal.columns ?? 80) - 2), title: choicePicker.title, notice: choicePicker.notice, options: filterChoiceOptions(choicePicker.options, choiceQuery), query: choicePickerSearchable(choicePicker) ? choiceQuery : undefined, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && agentSurface === null && outputViewer === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { working: state === 'thinking' || state === 'tool' || visibleActiveTools.length > 0, focus: phase === 'ready' &&
5296
5809
  state !== 'awaiting-permission' &&
5297
5810
  !compacting &&
5298
5811
  externalEditorRequest === null &&
@@ -5300,40 +5813,48 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
5300
5813
  textPrompt === null &&
5301
5814
  choicePicker === null &&
5302
5815
  copyPicker === null &&
5303
- agentSurface === null, hidden: permission !== null ||
5816
+ agentSurface === null && outputViewer === null, hidden: permission !== null ||
5304
5817
  textPrompt !== null ||
5305
5818
  choicePicker !== null ||
5306
5819
  copyPicker !== null ||
5307
- agentSurface?.kind === 'transcript', children: [pendingSteers.length > 0 &&
5820
+ (agentSurface !== null || outputViewer !== null), children: [pendingSteers.length > 0 &&
5308
5821
  permission === null &&
5309
5822
  textPrompt === null &&
5310
5823
  choicePicker === null &&
5311
5824
  copyPicker === null &&
5312
- agentSurface === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
5825
+ agentSurface === null && outputViewer === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
5313
5826
  permission === null &&
5314
5827
  textPrompt === null &&
5315
5828
  choicePicker === null &&
5316
5829
  copyPicker === null &&
5317
- agentSurface === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
5830
+ agentSurface === null && outputViewer === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
5318
5831
  ? queuePause.outcome === 'paused'
5319
5832
  ? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
5320
5833
  : `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
5321
- : 'sending when ready'] }) })) : null, _jsx(Composer, { permissionMode: displayedPermissionMode, onCycleMode: cyclePermissionMode, disabled: phase !== 'ready' ||
5834
+ : 'sending when ready'] }) })) : null, _jsx(Composer, { reasoningEffortLevels: session?.reasoningEffortLevels, permissionMode: displayedPermissionMode, onCycleMode: cyclePermissionMode, disabled: outputViewer !== null ||
5835
+ phase !== 'ready' ||
5322
5836
  state === 'awaiting-permission' ||
5323
5837
  compacting ||
5324
5838
  conversationMutation !== null ||
5325
5839
  externalEditorRequest !== null ||
5326
- agentSurface !== null, hidden: permission !== null ||
5840
+ (agentSurface !== null || outputViewer !== null), hidden: permission !== null ||
5327
5841
  textPrompt !== null ||
5328
5842
  choicePicker !== null ||
5329
5843
  copyPicker !== null ||
5330
- agentSurface?.kind === 'transcript',
5844
+ (agentSurface !== null || outputViewer !== null),
5331
5845
  // A turn is running, so Esc is the interrupt and not
5332
5846
  // the composer's clear.
5333
- escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), onDraftPresenceChange: setComposerHasDraft, onOpenAgentPanel: openAgentCockpit, userCommands: userCommands, builtins: hostCommands.map((command) => ({
5847
+ escapeInterrupts: !compacting &&
5848
+ (state === 'thinking' || state === 'tool' || pendingModelSwitchRef.current !== null), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), onDraftPresenceChange: setComposerHasDraft, onOpenAgentPanel: openAgentCockpit, userCommands: userCommands, builtins: hostCommands.map((command) => ({
5334
5849
  ...command,
5335
5850
  description: command.unavailable?.(slashCtx) ?? command.description,
5336
- })), mentionCandidates: mentionCandidates, history: history })] }), permission === null && agentSurface === null && liveSubagents.length > 0 ? (_jsx(AgentTaskPanel, { agents: liveSubagents, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'cockpit' ? (_jsx(AgentCockpit, { agents: subagents, selectedPhaseId: agentSurface.selectedPhaseId, selectedId: agentSurface.selectedId, focus: agentSurface.focus, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'transcript' && selectedSubagent ? (_jsx(AgentTranscript, { agent: selectedSubagent, tailOffset: agentSurface.tailOffset, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : null] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: statusGoal, state: state, hint: statusHint }) })] }) }));
5851
+ })), mentionCandidates: mentionCandidates, history: history })] }), permission === null && agentSurface === null && outputViewer === null && liveSubagents.length > 0 ? (_jsx(AgentTaskPanel, { agents: liveSubagents, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'cockpit' ? (_jsx(AgentCockpit, { agents: subagents, selectedPhaseId: agentSurface.selectedPhaseId, selectedId: agentSurface.selectedId, focus: agentSurface.focus, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'transcript' && selectedSubagent ? (_jsx(AgentTranscript, { agent: selectedSubagent, tailOffset: agentSurface.tailOffset, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : null] })), outputViewer && permission === null ? (_jsx(ToolOutputViewer, { onPrevious: () => {
5852
+ if (outputIndex > 0)
5853
+ setOutputViewer(outputBlocks[outputIndex - 1]);
5854
+ }, onNext: () => {
5855
+ if (outputIndex >= 0 && outputIndex + 1 < outputBlocks.length)
5856
+ setOutputViewer(outputBlocks[outputIndex + 1]);
5857
+ }, title: outputViewer.content, lines: outputViewer.detail ?? [], rows: terminal.rows, columns: terminal.columns, onClose: () => setOutputViewer(null) }, outputViewer.id)) : null, _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: statusGoal, state: state, hint: statusHint }) })] }) }));
5337
5858
  }
5338
5859
  /** One line for a background job that ended, for the transcript and the next turn. */
5339
5860
  function describeJobExit(job) {
@@ -5392,12 +5913,12 @@ hasDraft = false) {
5392
5913
  // screen; only state-specific actions belong in the footer.
5393
5914
  return '';
5394
5915
  }
5395
- function goalStatusLabel(goal) {
5916
+ function goalStatusLabel(goal, armed) {
5396
5917
  if (!goal)
5397
5918
  return null;
5398
5919
  switch (goal.phase) {
5399
5920
  case 'active':
5400
- return 'Pursuing goal';
5921
+ return armed ? 'Pursuing goal' : 'Goal paused (/goal resume)';
5401
5922
  case 'paused':
5402
5923
  return 'Goal paused (/goal resume)';
5403
5924
  case 'blocked':
@@ -5443,6 +5964,29 @@ function permissionPicker(mode, approvedAll, more = false) {
5443
5964
  }),
5444
5965
  };
5445
5966
  }
5967
+ function reasoningEffortPicker(session, current, afterModelSelection = false) {
5968
+ const levels = session.reasoningEffortLevels;
5969
+ if (!session.hasProvider || levels === undefined)
5970
+ return undefined;
5971
+ const values = [undefined, ...levels];
5972
+ return {
5973
+ kind: 'reasoning-effort',
5974
+ title: `Select Reasoning Level for ${session.modelSummary ?? 'current model'}`,
5975
+ ...(afterModelSelection
5976
+ ? {
5977
+ selectedSession: session,
5978
+ notice: 'Model selected. Esc keeps this model at its default effort.',
5979
+ }
5980
+ : {}),
5981
+ values,
5982
+ options: values.map((effort) => ({
5983
+ label: effort ?? 'default',
5984
+ description: reasoningEffortDescription(effort),
5985
+ current: effort === current,
5986
+ default: effort === undefined,
5987
+ })),
5988
+ };
5989
+ }
5446
5990
  function reasoningEffortDescription(effort) {
5447
5991
  switch (effort) {
5448
5992
  case undefined: