@namzu/cli 18.1.0 → 20.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 +5951 -0
  2. package/README.md +23 -24
  3. package/dist/bin.js +12 -1
  4. package/dist/bin.js.map +1 -1
  5. package/dist/checkpoints/store.d.ts +75 -0
  6. package/dist/checkpoints/store.d.ts.map +1 -0
  7. package/dist/checkpoints/store.js +177 -0
  8. package/dist/checkpoints/store.js.map +1 -0
  9. package/dist/checkpoints/wrap.d.ts +15 -0
  10. package/dist/checkpoints/wrap.d.ts.map +1 -0
  11. package/dist/checkpoints/wrap.js +37 -0
  12. package/dist/checkpoints/wrap.js.map +1 -0
  13. package/dist/cli.d.ts +6 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +12 -1
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/acp.d.ts.map +1 -1
  18. package/dist/commands/acp.js +2 -0
  19. package/dist/commands/acp.js.map +1 -1
  20. package/dist/commands/drain.d.ts.map +1 -1
  21. package/dist/commands/drain.js +15 -2
  22. package/dist/commands/drain.js.map +1 -1
  23. package/dist/commands/provider-wait.d.ts +44 -0
  24. package/dist/commands/provider-wait.d.ts.map +1 -0
  25. package/dist/commands/provider-wait.js +65 -0
  26. package/dist/commands/provider-wait.js.map +1 -0
  27. package/dist/commands/run-flags.d.ts +13 -1
  28. package/dist/commands/run-flags.d.ts.map +1 -1
  29. package/dist/commands/run-flags.js +41 -0
  30. package/dist/commands/run-flags.js.map +1 -1
  31. package/dist/commands/run-stream.d.ts.map +1 -1
  32. package/dist/commands/run-stream.js +33 -13
  33. package/dist/commands/run-stream.js.map +1 -1
  34. package/dist/commands/run.d.ts.map +1 -1
  35. package/dist/commands/run.js +132 -78
  36. package/dist/commands/run.js.map +1 -1
  37. package/dist/commands/stdin.d.ts +17 -0
  38. package/dist/commands/stdin.d.ts.map +1 -0
  39. package/dist/commands/stdin.js +69 -0
  40. package/dist/commands/stdin.js.map +1 -0
  41. package/dist/config/load.d.ts.map +1 -1
  42. package/dist/config/load.js +107 -2
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/schema.d.ts +80 -5
  45. package/dist/config/schema.d.ts.map +1 -1
  46. package/dist/config/schema.js.map +1 -1
  47. package/dist/context/doctrine.d.ts +16 -38
  48. package/dist/context/doctrine.d.ts.map +1 -1
  49. package/dist/context/doctrine.js +16 -75
  50. package/dist/context/doctrine.js.map +1 -1
  51. package/dist/context/environment.d.ts +2 -0
  52. package/dist/context/environment.d.ts.map +1 -1
  53. package/dist/context/environment.js +3 -0
  54. package/dist/context/environment.js.map +1 -1
  55. package/dist/integrations/mcp/servers.d.ts +19 -0
  56. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  57. package/dist/integrations/mcp/servers.js +23 -2
  58. package/dist/integrations/mcp/servers.js.map +1 -1
  59. package/dist/integrations/plugins/runtime.d.ts +8 -2
  60. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  61. package/dist/integrations/plugins/runtime.js +24 -12
  62. package/dist/integrations/plugins/runtime.js.map +1 -1
  63. package/dist/integrations/providers/chain-capabilities.js +1 -1
  64. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  65. package/dist/integrations/providers/credential-store.d.ts +1 -1
  66. package/dist/integrations/providers/credential-store.js +1 -1
  67. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  68. package/dist/integrations/providers/harness-credentials.js +7 -0
  69. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  70. package/dist/integrations/providers/registry.d.ts +2 -2
  71. package/dist/integrations/providers/registry.js +2 -2
  72. package/dist/integrations/sessions/store.d.ts +14 -22
  73. package/dist/integrations/sessions/store.d.ts.map +1 -1
  74. package/dist/integrations/sessions/store.js +78 -175
  75. package/dist/integrations/sessions/store.js.map +1 -1
  76. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  77. package/dist/integrations/sessions/transcript-export.js +6 -2
  78. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  79. package/dist/integrations/state/identity.d.ts +21 -0
  80. package/dist/integrations/state/identity.d.ts.map +1 -0
  81. package/dist/integrations/state/identity.js +49 -0
  82. package/dist/integrations/state/identity.js.map +1 -0
  83. package/dist/integrations/state/immutable-json.d.ts +10 -0
  84. package/dist/integrations/state/immutable-json.d.ts.map +1 -0
  85. package/dist/integrations/state/immutable-json.js +38 -0
  86. package/dist/integrations/state/immutable-json.js.map +1 -0
  87. package/dist/integrations/state/project.d.ts +10 -0
  88. package/dist/integrations/state/project.d.ts.map +1 -0
  89. package/dist/integrations/state/project.js +15 -0
  90. package/dist/integrations/state/project.js.map +1 -0
  91. package/dist/integrations/state/report.d.ts.map +1 -1
  92. package/dist/integrations/state/report.js +42 -12
  93. package/dist/integrations/state/report.js.map +1 -1
  94. package/dist/integrations/subagents/definitions.d.ts +19 -0
  95. package/dist/integrations/subagents/definitions.d.ts.map +1 -0
  96. package/dist/integrations/subagents/definitions.js +26 -0
  97. package/dist/integrations/subagents/definitions.js.map +1 -0
  98. package/dist/integrations/subagents/parent.d.ts +13 -0
  99. package/dist/integrations/subagents/parent.d.ts.map +1 -0
  100. package/dist/integrations/subagents/parent.js +57 -0
  101. package/dist/integrations/subagents/parent.js.map +1 -0
  102. package/dist/integrations/subagents/runtime.d.ts +33 -16
  103. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  104. package/dist/integrations/subagents/runtime.js +450 -150
  105. package/dist/integrations/subagents/runtime.js.map +1 -1
  106. package/dist/memory/presentation.d.ts +9 -0
  107. package/dist/memory/presentation.d.ts.map +1 -0
  108. package/dist/memory/presentation.js +37 -0
  109. package/dist/memory/presentation.js.map +1 -0
  110. package/dist/memory/store.d.ts +35 -15
  111. package/dist/memory/store.d.ts.map +1 -1
  112. package/dist/memory/store.js +79 -25
  113. package/dist/memory/store.js.map +1 -1
  114. package/dist/permissions/mode.d.ts +15 -45
  115. package/dist/permissions/mode.d.ts.map +1 -1
  116. package/dist/permissions/mode.js +36 -13
  117. package/dist/permissions/mode.js.map +1 -1
  118. package/dist/release-notes.d.ts +20 -0
  119. package/dist/release-notes.d.ts.map +1 -0
  120. package/dist/release-notes.js +64 -0
  121. package/dist/release-notes.js.map +1 -0
  122. package/dist/tui/AgentExplorer.d.ts +1 -1
  123. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  124. package/dist/tui/AgentExplorer.js +25 -21
  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 +701 -245
  128. package/dist/tui/App.js.map +1 -1
  129. package/dist/tui/BrandHeader.d.ts +12 -0
  130. package/dist/tui/BrandHeader.d.ts.map +1 -0
  131. package/dist/tui/BrandHeader.js +18 -0
  132. package/dist/tui/BrandHeader.js.map +1 -0
  133. package/dist/tui/ChoicePicker.d.ts +11 -1
  134. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  135. package/dist/tui/ChoicePicker.js +46 -13
  136. package/dist/tui/ChoicePicker.js.map +1 -1
  137. package/dist/tui/Composer.d.ts +5 -2
  138. package/dist/tui/Composer.d.ts.map +1 -1
  139. package/dist/tui/Composer.js +6 -11
  140. package/dist/tui/Composer.js.map +1 -1
  141. package/dist/tui/ComposerFrame.d.ts +10 -0
  142. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  143. package/dist/tui/ComposerFrame.js +93 -0
  144. package/dist/tui/ComposerFrame.js.map +1 -0
  145. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  146. package/dist/tui/EditPromptPicker.js +1 -1
  147. package/dist/tui/EditPromptPicker.js.map +1 -1
  148. package/dist/tui/LiveActivity.d.ts +12 -5
  149. package/dist/tui/LiveActivity.d.ts.map +1 -1
  150. package/dist/tui/LiveActivity.js +17 -32
  151. package/dist/tui/LiveActivity.js.map +1 -1
  152. package/dist/tui/PermissionOverlay.d.ts +26 -2
  153. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  154. package/dist/tui/PermissionOverlay.js +109 -16
  155. package/dist/tui/PermissionOverlay.js.map +1 -1
  156. package/dist/tui/Picker.d.ts +2 -2
  157. package/dist/tui/Picker.d.ts.map +1 -1
  158. package/dist/tui/Picker.js +82 -52
  159. package/dist/tui/Picker.js.map +1 -1
  160. package/dist/tui/StatusBar.d.ts.map +1 -1
  161. package/dist/tui/StatusBar.js +5 -2
  162. package/dist/tui/StatusBar.js.map +1 -1
  163. package/dist/tui/TaskList.d.ts +3 -1
  164. package/dist/tui/TaskList.d.ts.map +1 -1
  165. package/dist/tui/TaskList.js +13 -4
  166. package/dist/tui/TaskList.js.map +1 -1
  167. package/dist/tui/Transcript.d.ts +4 -12
  168. package/dist/tui/Transcript.d.ts.map +1 -1
  169. package/dist/tui/Transcript.js +45 -75
  170. package/dist/tui/Transcript.js.map +1 -1
  171. package/dist/tui/agent.d.ts +116 -51
  172. package/dist/tui/agent.d.ts.map +1 -1
  173. package/dist/tui/agent.js +539 -307
  174. package/dist/tui/agent.js.map +1 -1
  175. package/dist/tui/choice-selection.d.ts +17 -0
  176. package/dist/tui/choice-selection.d.ts.map +1 -0
  177. package/dist/tui/choice-selection.js +38 -0
  178. package/dist/tui/choice-selection.js.map +1 -0
  179. package/dist/tui/exit-summary.d.ts +7 -1
  180. package/dist/tui/exit-summary.d.ts.map +1 -1
  181. package/dist/tui/exit-summary.js +12 -3
  182. package/dist/tui/exit-summary.js.map +1 -1
  183. package/dist/tui/index.d.ts +3 -1
  184. package/dist/tui/index.d.ts.map +1 -1
  185. package/dist/tui/index.js +5 -3
  186. package/dist/tui/index.js.map +1 -1
  187. package/dist/tui/logo.d.ts +7 -22
  188. package/dist/tui/logo.d.ts.map +1 -1
  189. package/dist/tui/logo.js +8 -34
  190. package/dist/tui/logo.js.map +1 -1
  191. package/dist/tui/model-choices.d.ts.map +1 -1
  192. package/dist/tui/model-choices.js +21 -6
  193. package/dist/tui/model-choices.js.map +1 -1
  194. package/dist/tui/permission-review.d.ts +15 -4
  195. package/dist/tui/permission-review.d.ts.map +1 -1
  196. package/dist/tui/permission-review.js +48 -9
  197. package/dist/tui/permission-review.js.map +1 -1
  198. package/dist/tui/provider-selection.d.ts +4 -0
  199. package/dist/tui/provider-selection.d.ts.map +1 -0
  200. package/dist/tui/provider-selection.js +16 -0
  201. package/dist/tui/provider-selection.js.map +1 -0
  202. package/dist/tui/run-interruption.d.ts +3 -2
  203. package/dist/tui/run-interruption.d.ts.map +1 -1
  204. package/dist/tui/run-interruption.js +2 -1
  205. package/dist/tui/run-interruption.js.map +1 -1
  206. package/dist/tui/shell-escape.d.ts +42 -0
  207. package/dist/tui/shell-escape.d.ts.map +1 -0
  208. package/dist/tui/shell-escape.js +110 -0
  209. package/dist/tui/shell-escape.js.map +1 -0
  210. package/dist/tui/slashCommands.d.ts +82 -31
  211. package/dist/tui/slashCommands.d.ts.map +1 -1
  212. package/dist/tui/slashCommands.js +518 -306
  213. package/dist/tui/slashCommands.js.map +1 -1
  214. package/dist/tui/terminal-choice-text.d.ts +8 -0
  215. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  216. package/dist/tui/terminal-choice-text.js +37 -0
  217. package/dist/tui/terminal-choice-text.js.map +1 -0
  218. package/dist/tui/theme.d.ts +3 -4
  219. package/dist/tui/theme.d.ts.map +1 -1
  220. package/dist/tui/theme.js +16 -17
  221. package/dist/tui/theme.js.map +1 -1
  222. package/dist/tui/types.d.ts +9 -13
  223. package/dist/tui/types.d.ts.map +1 -1
  224. package/package.json +7 -5
package/dist/tui/App.js CHANGED
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * automatic reuse is not mistaken for a durable operator preference.
13
13
  */
14
14
  import { join, relative } from 'node:path';
15
- import { DefaultPathBuilder, DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, generateSessionId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
15
+ import { DEFAULT_MAX_GOAL_ROUNDS, DefaultPathBuilder, DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, generateSessionId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
16
  import { Box, Text, useApp, useInput, useStdout, useWindowSize } from 'ink';
17
17
  import { useCallback, useEffect, useRef, useState } from 'react';
18
18
  import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
@@ -25,24 +25,29 @@ import { appendMessages, archiveConversation, forkConversation, forkConversation
25
25
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
26
26
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
27
27
  import { checkUpdates } from '../integrations/updates.js';
28
- import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.js';
28
+ import { renderMemoryReport } from '../memory/presentation.js';
29
+ import { appendMemory, readMemory } from '../memory/store.js';
30
+ import { effectivePermissionMode, permissionModeDescription, permissionModeLabel, } from '../permissions/mode.js';
29
31
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
30
32
  import { discoverUserCommands } from '../user-commands/store.js';
31
33
  import { AgentCockpit, AgentTaskPanel, AgentTranscript, activeSubagentCohorts, agentCockpitIsWide, agentPhasePageSize, agentPhases, agentPickerPageSize, agentTranscriptPageSize, maxAgentTranscriptTailOffset, } from './AgentExplorer.js';
34
+ import { BrandHeader } from './BrandHeader.js';
32
35
  import { ChoicePicker } from './ChoicePicker.js';
33
36
  import { Composer, suggestionWindowSize, } from './Composer.js';
37
+ import { ComposerFrame } from './ComposerFrame.js';
34
38
  import { CopyPicker } from './CopyPicker.js';
35
39
  import { EditPromptPicker } from './EditPromptPicker.js';
36
40
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
37
- import { TaskList } from './TaskList.js';
38
41
  import { PermissionOverlay } from './PermissionOverlay.js';
39
42
  import { Picker } from './Picker.js';
40
43
  import { ResumePicker } from './ResumePicker.js';
41
44
  import { StatusBar } from './StatusBar.js';
45
+ import { TaskList } from './TaskList.js';
42
46
  import { TextPrompt } from './TextPrompt.js';
43
47
  import { Transcript, willCollapse } from './Transcript.js';
44
48
  import { TrustPrompt } from './TrustPrompt.js';
45
49
  import { createAgentSession, probeAgentSession, } from './agent.js';
50
+ import { choicePickerWindowSize, filterChoiceOptions, moveChoiceSelection, } from './choice-selection.js';
46
51
  import { keepRecentRows } from './compact-transcript.js';
47
52
  import { approvalIsDeliberate } from './consent-timing.js';
48
53
  import { planTurnPublication } from './conversation-history.js';
@@ -51,12 +56,15 @@ import { editablePrompts } from './edit-prompts.js';
51
56
  import { editDraftInExternalEditor } from './external-editor.js';
52
57
  import { liveWindow } from './live-window.js';
53
58
  import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
54
- import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
59
+ import { selectPrimaryProvider } from './provider-selection.js';
60
+ import { eraseLastChoiceGrapheme } from './terminal-choice-text.js';
61
+ import { renderCheckpoints, renderRestore } from '../checkpoints/store.js';
55
62
  import { expandFileMentions, listMentionableFiles } from './mentions.js';
56
63
  import { openInBrowser } from './open-browser.js';
57
- import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, buildPermissionSummary, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
64
+ import { buildPermissionReview, buildPermissionSummary, permissionReviewPageRows, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
58
65
  import { describeRunInterruption } from './run-interruption.js';
59
66
  import { moveSelection } from './selection-window.js';
67
+ import { describeShellEscape, describeShellEscapeForModel, runShellEscape, shellEscapeCommand, } from './shell-escape.js';
60
68
  import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
61
69
  import { splitCompleteBlocks, splitSafeCut } from './stream-blocks.js';
62
70
  import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
@@ -64,6 +72,8 @@ import { theme } from './theme.js';
64
72
  import { useSelectionIndex } from './use-selection-index.js';
65
73
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
66
74
  import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
75
+ /** How long `/exit` waits for the session to close before leaving anyway. */
76
+ const SESSION_CLOSE_ON_EXIT_MS = 5_000;
67
77
  /** The value a question row carries: an option id, or the free-text escape. */
68
78
  const FREE_TEXT_ANSWER = '__free_text__';
69
79
  /**
@@ -305,7 +315,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
305
315
  const [pickerDetected, setPickerDetected] = useState(null);
306
316
  /** Whether Enter accepts the provider directly or continues to model choice. */
307
317
  const [pickerSelectionKind, setPickerSelectionKind] = useState('provider-and-model');
308
- /** The chain read from disk, held while the picker repairs its credential. */
318
+ /** Active preferences, or the saved chain awaiting its missing credential. */
309
319
  const savedPrefsRef = useRef(null);
310
320
  /**
311
321
  * Why the picker is open, drawn ON the picker.
@@ -345,6 +355,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
345
355
  permissionReviewOffsetRef.current = next;
346
356
  setPermissionReviewOffsetState(next);
347
357
  }, []);
358
+ // Which of the three answers the cursor is on. Opens on "Yes", as the
359
+ // prompt an operator knows from elsewhere does; Enter confirms it only
360
+ // after the same settle window `y` and `a` wait out.
361
+ const [permissionChoice, setPermissionChoiceState] = useState(0);
362
+ const permissionChoiceRef = useRef(0);
363
+ const setPermissionChoice = useCallback((next) => {
364
+ permissionChoiceRef.current = next;
365
+ setPermissionChoiceState(next);
366
+ }, []);
348
367
  // The launch bypass is an INITIAL selection, not permanent authority. A
349
368
  // typed /permissions command can narrow it back to prompt/strict without
350
369
  // rebuilding the provider, tools, plugins, or sandbox.
@@ -366,7 +385,62 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
366
385
  /** Git-admitted file paths cached once per successful session hydration. */
367
386
  const [mentionCandidates, setMentionCandidates] = useState([]);
368
387
  const mentionLoadOwnerRef = useRef(null);
388
+ // What compaction has done this session, summed over passes; `/context`
389
+ // prints it. Reset with the session, never by a pass that declined.
390
+ const [compactionTally, setCompactionTally] = useState({
391
+ passes: 0,
392
+ clearedResults: 0,
393
+ stubbedNarrations: 0,
394
+ summaries: 0,
395
+ reclaimedTokens: 0,
396
+ });
397
+ // Jobs that ended while no turn was running. The kernel's notice needs a
398
+ // tool result to ride on, so between turns the exit is held here and
399
+ // handed to the next send as part of the system text; the transcript
400
+ // row is written at once.
401
+ const idleJobNoticesRef = useRef([]);
402
+ const drainIdleJobNotices = () => {
403
+ if (idleJobNoticesRef.current.length === 0)
404
+ return undefined;
405
+ const lines = idleJobNoticesRef.current;
406
+ idleJobNoticesRef.current = [];
407
+ return `Background jobs that ended since your last turn:\n${lines.map((l) => `- ${l}`).join('\n')}`;
408
+ };
409
+ // Commands the operator ran with `!` since the last turn. The model did
410
+ // not see them happen; it reads them here on its next turn.
411
+ const operatorShellRef = useRef([]);
412
+ const drainOperatorShell = () => {
413
+ if (operatorShellRef.current.length === 0)
414
+ return undefined;
415
+ const blocks = operatorShellRef.current;
416
+ operatorShellRef.current = [];
417
+ return `What the operator did outside you since your last turn — commands they ran in their own shell, files they put back — with the output:\n\n${blocks.join('\n\n')}`;
418
+ };
419
+ useEffect(() => {
420
+ if (!session?.onJobExit)
421
+ return;
422
+ return session.onJobExit((job) => {
423
+ if (abortRef.current !== null)
424
+ return;
425
+ const text = describeJobExit({
426
+ jobId: job.id,
427
+ command: job.command,
428
+ status: job.status === 'killed' ? 'killed' : 'exited',
429
+ ...(job.exitCode !== undefined ? { exitCode: job.exitCode } : {}),
430
+ ...(job.signal ? { signal: job.signal } : {}),
431
+ });
432
+ idleJobNoticesRef.current.push(text);
433
+ pushMessage('system', text, false, '⚙');
434
+ });
435
+ }, [session]);
369
436
  const [usage, setUsage] = useState(null);
437
+ /**
438
+ * The model's current reasoning, last line only, for the live region.
439
+ * Never appended to a message: the kernel keeps reasoning out of its
440
+ * own transcript, and this exists so a long think reads as work rather
441
+ * than as silence. Cleared by the first text or tool call that follows.
442
+ */
443
+ const [thinking, setThinking] = useState(null);
370
444
  // Tools currently executing — rendered live (spinner + elapsed) below the
371
445
  // transcript, then committed as static lines on completion.
372
446
  const [activeTools, setActiveTools] = useState([]);
@@ -459,6 +533,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
459
533
  /** Finite slash-command choice owned synchronously until apply or cancel. */
460
534
  const [choicePicker, setChoicePickerState] = useState(null);
461
535
  const choicePickerRef = useRef(null);
536
+ const [choiceQuery, setChoiceQuery] = useState('');
537
+ const choiceQueryRef = useRef('');
462
538
  /**
463
539
  * Exact chooser React has committed. The synchronous owner ref is needed to
464
540
  * block queue/goal work immediately, but it must not accept the Return key
@@ -469,6 +545,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
469
545
  const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
470
546
  const setChoicePicker = useCallback((next) => {
471
547
  choicePickerRef.current = next;
548
+ choiceQueryRef.current = '';
549
+ setChoiceQuery('');
472
550
  if (next === null)
473
551
  choicePickerCommittedRef.current = null;
474
552
  setChoicePickerState(next);
@@ -514,14 +592,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
514
592
  const surface = agentSurfaceRef.current;
515
593
  if (!surface)
516
594
  return;
517
- const activeCohorts = activeSubagentCohorts(next);
518
- if (activeCohorts.length === 0) {
595
+ if (next.length === 0) {
519
596
  setAgentSurface(null);
520
597
  return;
521
598
  }
522
- if (activeCohorts.some((agent) => agent.viewId === surface.selectedId))
599
+ if (next.some((agent) => agent.viewId === surface.selectedId))
523
600
  return;
524
- const phases = agentPhases(activeCohorts);
601
+ const phases = agentPhases(next);
525
602
  const fallbackPhase = phases.find((phase) => phase.id === surface.selectedPhaseId) ?? phases[0];
526
603
  const fallbackAgent = fallbackPhase?.agents[0];
527
604
  if (!fallbackPhase || !fallbackAgent) {
@@ -536,7 +613,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
536
613
  });
537
614
  }, [setAgentSurface]);
538
615
  const openAgentCockpit = useCallback(() => {
539
- const agents = activeSubagentCohorts(subagentsRef.current);
616
+ const agents = subagentsRef.current;
540
617
  const firstPhase = agentPhases(agents)[0];
541
618
  const firstAgent = firstPhase?.agents[0];
542
619
  if (!firstPhase || !firstAgent)
@@ -642,14 +719,29 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
642
719
  */
643
720
  const conversationMaterializedRef = useRef(false);
644
721
  const materializationRef = useRef(null);
722
+ /**
723
+ * The session owns processes — MCP servers, background jobs, the hooks'
724
+ * `session_end` — and Ink's exit does not close it. So the session is
725
+ * closed first, bounded, and the process leaves after: a server started
726
+ * with `run_in_background` used to outlive `/exit`, and a `session_end`
727
+ * hook never ran.
728
+ */
729
+ const closeAndExit = useCallback(() => {
730
+ const closing = session?.close() ?? Promise.resolve();
731
+ const bounded = Promise.race([
732
+ closing,
733
+ new Promise((resolve) => setTimeout(resolve, SESSION_CLOSE_ON_EXIT_MS).unref?.()),
734
+ ]);
735
+ void bounded.catch(() => undefined).finally(() => exit());
736
+ }, [exit, session]);
645
737
  const exitWithSummary = useCallback(() => {
646
738
  onExitSummary?.({
647
739
  ...(conversationMaterializedRef.current && scopeRef.current?.sessionId
648
740
  ? { conversationId: scopeRef.current.sessionId }
649
741
  : {}),
650
742
  });
651
- exit();
652
- }, [exit, onExitSummary]);
743
+ closeAndExit();
744
+ }, [closeAndExit, onExitSummary]);
653
745
  /** Durable active state is not permission to spend turns after a restart. */
654
746
  const [goalActivation] = useState(() => new SessionGoalActivation());
655
747
  const goalDriveInFlightRef = useRef(false);
@@ -849,7 +941,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
849
941
  }, [nextId]);
850
942
  const applyPermissionMode = useCallback((mode) => {
851
943
  if (!session?.hasProvider) {
852
- pushMessage('system', 'No active session — pick a provider before changing permissions.');
944
+ pushMessage('system', 'Choose a model before changing permissions.');
853
945
  return;
854
946
  }
855
947
  if (state !== 'idle' ||
@@ -858,18 +950,18 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
858
950
  queuedRef.current.length > 0 ||
859
951
  permissionResolveRef.current !== null ||
860
952
  compactingRef.current) {
861
- pushMessage('system', 'Permission mode was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
953
+ pushMessage('system', 'Permissions were not changed. Finish or stop the current work first.');
862
954
  return;
863
955
  }
864
956
  if (!session.resetApprovalLatch) {
865
- pushMessage('system', 'Permission mode was not changed: this embedded session cannot revoke an earlier "approve all" choice. Reconnect it before changing modes.');
957
+ pushMessage('system', 'This session cannot reset approvals. Reconnect before changing permissions.');
866
958
  return;
867
959
  }
868
960
  session.resetApprovalLatch();
869
961
  permissionModeRef.current = mode;
870
962
  permissionModeSourceRef.current = 'session';
871
963
  setPermissionModeState(mode);
872
- pushMessage('system', `Permission mode changed to ${mode}. Any earlier "approve all" choice was revoked. Declarative deny rules and the built-in safety gate still take precedence.`);
964
+ pushMessage('system', `Permissions: ${permissionModeLabel(mode)} for this session. ${permissionModeDescription(mode)}`);
873
965
  }, [hasUnsettledTurn, pushMessage, session, state]);
874
966
  const applyReasoningEffort = useCallback((effort) => {
875
967
  if (!session?.hasProvider) {
@@ -1038,7 +1130,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1038
1130
  archived = true;
1039
1131
  goalActivation.clear();
1040
1132
  onExitSummary?.({});
1041
- exit();
1133
+ closeAndExit();
1042
1134
  }
1043
1135
  catch (error) {
1044
1136
  pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
@@ -1147,6 +1239,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1147
1239
  const value = picker.values[index];
1148
1240
  if (index < 0 || index >= picker.values.length)
1149
1241
  return;
1242
+ if (picker.options[index]?.disabledReason)
1243
+ return;
1150
1244
  if (picker.kind === 'review-preset') {
1151
1245
  if (reviewChoiceInFlightRef.current)
1152
1246
  return;
@@ -1177,6 +1271,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1177
1271
  setSelectedChoice(0);
1178
1272
  setChoicePicker({
1179
1273
  kind: 'review-branch',
1274
+ back: picker,
1180
1275
  title: 'Select a base branch',
1181
1276
  notice: `Current branch: ${listing.current}`,
1182
1277
  values: listing.branches,
@@ -1201,6 +1296,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1201
1296
  setSelectedChoice(0);
1202
1297
  setChoicePicker({
1203
1298
  kind: 'review-commit',
1299
+ back: picker,
1204
1300
  title: 'Select a commit to review',
1205
1301
  values: commits,
1206
1302
  options: commits.map((commit) => ({
@@ -1323,6 +1419,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1323
1419
  return;
1324
1420
  }
1325
1421
  commandPickerSubmitRef.current(`/${command.name}`);
1422
+ const child = choicePickerRef.current;
1423
+ if (child)
1424
+ setChoicePicker({ ...child, back: picker });
1326
1425
  return;
1327
1426
  }
1328
1427
  if (picker.kind === 'archive-conversation') {
@@ -1353,6 +1452,16 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1353
1452
  return;
1354
1453
  }
1355
1454
  if (picker.kind === 'permission-mode') {
1455
+ if (value === 'more') {
1456
+ const child = permissionPicker(permissionModeRef.current, session?.approvalLatched() ?? false, true);
1457
+ setSelectedChoice(Math.max(0, child.options.findIndex((option) => option.current)));
1458
+ setChoicePicker({ ...child, back: picker });
1459
+ return;
1460
+ }
1461
+ if (value === 'rules') {
1462
+ commandPickerSubmitRef.current('/permissions details');
1463
+ return;
1464
+ }
1356
1465
  applyPermissionMode(value);
1357
1466
  return;
1358
1467
  }
@@ -1383,7 +1492,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1383
1492
  removeStoredCredential,
1384
1493
  runConversationExport,
1385
1494
  setChoicePicker,
1495
+ setSelectedChoice,
1386
1496
  setTextPrompt,
1497
+ session,
1387
1498
  ]);
1388
1499
  const sendCopyRequest = useCallback((index) => {
1389
1500
  const picker = copyPickerRef.current;
@@ -1504,6 +1615,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1504
1615
  else {
1505
1616
  // Provider/tool startup needs stable project/tenant/topic ids, not a
1506
1617
  // durable conversation. The cursor is replaced on first admitted use.
1618
+ // The conversation's id, chosen now; the conversation is written
1619
+ // under it at first durable use. Nothing that saw this id before
1620
+ // that moment — a hook, a log line, the screen — is later wrong.
1507
1621
  sessionId = generateSessionId();
1508
1622
  }
1509
1623
  sessionsRef.current = sessions;
@@ -1531,11 +1645,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1531
1645
  return undefined;
1532
1646
  conversationMutationRef.current = 'materialize';
1533
1647
  try {
1534
- const sessionId = await startConversation(sessions);
1648
+ const written = await startConversation(sessions, scope.sessionId);
1535
1649
  if (scopeRef.current !== scope || conversationMaterializedRef.current) {
1536
1650
  throw new Error('the active conversation changed while its first session was published');
1537
1651
  }
1538
- scope.sessionId = sessionId;
1652
+ // The store creates the conversation under the id chosen at open,
1653
+ // so this is the same id; a store that cannot honour a chosen id
1654
+ // is followed rather than contradicted.
1655
+ if (written !== scope.sessionId)
1656
+ scope.sessionId = written;
1539
1657
  conversationMaterializedRef.current = true;
1540
1658
  return scope;
1541
1659
  }
@@ -1554,7 +1672,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1554
1672
  materializationRef.current = null;
1555
1673
  }
1556
1674
  }, [ensureSessions]);
1557
- const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
1675
+ const hydrateSession = useCallback(async (prefs, detectedNow, signal, persistSelection = false) => {
1558
1676
  if (signal?.aborted)
1559
1677
  return;
1560
1678
  const scope = await ensureSessions();
@@ -1564,15 +1682,18 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1564
1682
  const s = await createAgentSession(prefs, detectedNow, {
1565
1683
  scope,
1566
1684
  cwd: activeCtx.cwd,
1567
- ...(sessionsRef.current?.backend === 'central'
1568
- ? { stateRoot: sessionsRef.current.root }
1685
+ ...(activeCtx.additionalDirectories
1686
+ ? { additionalDirectories: activeCtx.additionalDirectories }
1569
1687
  : {}),
1688
+ ...(sessionsRef.current ? { stateRoot: sessionsRef.current.root } : {}),
1570
1689
  enableComputerUse: true,
1571
1690
  rules: activeCtx.rules,
1572
1691
  ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
1573
1692
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
1574
1693
  ...(activeCtx.plugins ? { plugins: activeCtx.plugins } : {}),
1575
1694
  ...(activeCtx.web ? { web: activeCtx.web } : {}),
1695
+ ...(activeCtx.hooks ? { hooks: activeCtx.hooks } : {}),
1696
+ ...(activeCtx.compaction ? { compaction: activeCtx.compaction } : {}),
1576
1697
  ...(activeCtx.sandbox ? { sandbox: activeCtx.sandbox } : {}),
1577
1698
  // Somebody is at this terminal, so the model may ask them one
1578
1699
  // question when a decision is genuinely theirs.
@@ -1593,6 +1714,24 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1593
1714
  }
1594
1715
  throw new Error(reason);
1595
1716
  }
1717
+ let commands;
1718
+ try {
1719
+ commands = discoverUserCommands({ cwd: activeCtx.cwd, reserved: hostCommandNames() });
1720
+ // Construction and validation precede the saved change. There is no
1721
+ // await between this atomic write and publishing the usable session.
1722
+ if (persistSelection)
1723
+ writePreferences(prefs);
1724
+ }
1725
+ catch (error) {
1726
+ try {
1727
+ await s.close();
1728
+ }
1729
+ catch {
1730
+ // Preserve the admission/save error and the still-active old session.
1731
+ }
1732
+ throw error;
1733
+ }
1734
+ savedPrefsRef.current = prefs;
1596
1735
  // A picker-owned provider/model change is one state transition. Clear the
1597
1736
  // old model's effort selection before publishing the replacement session
1598
1737
  // or releasing any paused queue. Failed and superseded candidates returned
@@ -1605,13 +1744,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1605
1744
  void previousSessionRef.current?.close();
1606
1745
  previousSessionRef.current = s;
1607
1746
  setSession(s);
1608
- setUserCommands(discoverUserCommands({
1609
- cwd: activeCtx.cwd,
1610
- // Builtins are reserved: a `help.md` must not take over `/help`.
1611
- // Passing the names here is what lets the loader tell its author
1612
- // the file is shadowed instead of leaving it silently unused.
1613
- reserved: hostCommandNames(),
1614
- }));
1747
+ setUserCommands(commands);
1615
1748
  const mentionLoadOwner = {};
1616
1749
  mentionLoadOwnerRef.current = mentionLoadOwner;
1617
1750
  void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
@@ -1973,6 +2106,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1973
2106
  }
1974
2107
  }, [activateTrustedProject, pushMessage, runProbe]);
1975
2108
  const finalized = messages.filter((m) => !m.pending);
2109
+ // Activity and the plan share the terminal with the draft. Collapse the two
2110
+ // lists together only when their full previews would crowd the input area.
2111
+ const fullTaskFurniture = tasks.length === 0 ? 0 : Math.min(tasks.length, 8) + 3;
2112
+ const fullToolFurniture = activeTools.length === 0 ? 0 : Math.min(activeTools.length, 3) * 2 + 2;
2113
+ const compactWork = LIVE_FURNITURE_ROWS + fullTaskFurniture + fullToolFurniture >= terminal.rows;
2114
+ const taskFurniture = compactWork && fullTaskFurniture > 0 ? 2 : fullTaskFurniture;
2115
+ const toolFurniture = compactWork && fullToolFurniture > 0 ? 2 : fullToolFurniture;
1976
2116
  // How much of the transcript is still redrawable. The rest belongs to native
1977
2117
  // terminal scrollback; the live tail stays deliberately small so an activity
1978
2118
  // tick never repaints the whole conversation.
@@ -1984,7 +2124,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1984
2124
  messages: finalized,
1985
2125
  rows: terminal.rows,
1986
2126
  columns: terminal.columns,
1987
- furnitureRows: LIVE_FURNITURE_ROWS,
2127
+ furnitureRows: LIVE_FURNITURE_ROWS + taskFurniture + toolFurniture,
1988
2128
  settled: settledRef.current,
1989
2129
  raw: rawOutput,
1990
2130
  });
@@ -2003,6 +2143,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2003
2143
  const hostCommands = mergeHostCommands(kernelCommandDescriptors());
2004
2144
  const slashCtx = {
2005
2145
  cwd: ctx.cwd,
2146
+ jobs: () => session?.jobs?.() ?? [],
2147
+ hooks: () => session?.hooks,
2148
+ directories: () => session?.directories?.list() ?? [],
2149
+ compaction: session
2150
+ ? {
2151
+ strategy: ctx.compaction?.strategy ?? 'salience',
2152
+ softTarget: 0.5,
2153
+ triggerThreshold: 0.7,
2154
+ ...compactionTally,
2155
+ }
2156
+ : null,
2006
2157
  builtins: hostCommands,
2007
2158
  lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
2008
2159
  // Called when `/tools` renders, not read here — the same shape, and the
@@ -2081,6 +2232,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2081
2232
  permissionOpenedAtRef.current = null;
2082
2233
  setPermissionReviewOffset(0);
2083
2234
  setPermissionDetailsOpen(false);
2235
+ setPermissionChoice(0);
2084
2236
  setPermission(null);
2085
2237
  if (resolve) {
2086
2238
  // The provider resumes asynchronously after the decision. Leaving the
@@ -2207,6 +2359,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2207
2359
  wakeGoalDriver();
2208
2360
  conversationGenRef.current += 1;
2209
2361
  resetSubagentActivity();
2362
+ session?.resetTaskStore?.();
2210
2363
  activeTurnTokenRef.current = null;
2211
2364
  resetTranscript();
2212
2365
  setMessages(restored);
@@ -2241,6 +2394,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2241
2394
  nextId,
2242
2395
  pushMessage,
2243
2396
  resetSubagentActivity,
2397
+ session,
2244
2398
  resetTranscript,
2245
2399
  wakeGoalDriver,
2246
2400
  ]);
@@ -2281,6 +2435,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2281
2435
  wakeGoalDriver();
2282
2436
  conversationGenRef.current += 1;
2283
2437
  resetSubagentActivity();
2438
+ session?.resetTaskStore?.();
2284
2439
  activeTurnTokenRef.current = null;
2285
2440
  modelHistoryRef.current = [];
2286
2441
  lastAssistantMessage.current = null;
@@ -2320,6 +2475,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2320
2475
  interruptTurn,
2321
2476
  pushMessage,
2322
2477
  resetSubagentActivity,
2478
+ session,
2323
2479
  resetTranscript,
2324
2480
  wakeGoalDriver,
2325
2481
  ]);
@@ -2382,9 +2538,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2382
2538
  }
2383
2539
  const scope = scopeRef.current;
2384
2540
  if (!scope || scope.sessionId !== prompt.sessionId) {
2385
- pushMessage('system', prompt.kind === 'conversation-title'
2386
- ? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
2387
- : 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
2541
+ pushMessage('system', 'The conversation changed while this editor was open. Reopen the command in the current conversation.');
2542
+ return;
2543
+ }
2544
+ if (prompt.kind === 'goal-create' || prompt.kind === 'goal-edit') {
2545
+ if (value.trim())
2546
+ commandPickerSubmitRef.current(`/goal ${prompt.kind === 'goal-create' ? 'set' : 'edit'} ${value.trim()}`);
2388
2547
  return;
2389
2548
  }
2390
2549
  if (prompt.kind === 'export-file') {
@@ -2461,6 +2620,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2461
2620
  // is reloaded or reset. Only where the NEXT turn is written changes.
2462
2621
  scope.sessionId = forked.id;
2463
2622
  resetSubagentActivity();
2623
+ session?.resetTaskStore?.();
2464
2624
  goalActivation.clear();
2465
2625
  wakeGoalDriver();
2466
2626
  pushMessage('system', `Forked into "${forked.title}" — ${forked.copied} message(s) copied. This screen continues in the copy; ${original} is unchanged and still in /resume.`);
@@ -2478,6 +2638,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2478
2638
  materializeConversation,
2479
2639
  pushMessage,
2480
2640
  resetSubagentActivity,
2641
+ session,
2481
2642
  wakeGoalDriver,
2482
2643
  ]);
2483
2644
  /** Open the durable prompt picker after the composer's second empty Esc. */
@@ -2502,6 +2663,33 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2502
2663
  editCommittedRef.current = false;
2503
2664
  setPhase('edit');
2504
2665
  }, [hasUnsettledTurn, messages, pushMessage, state]);
2666
+ /** `/restore`: list the file checkpoints, or put the tree back to before a turn. */
2667
+ const doRestore = useCallback(async (turn) => {
2668
+ const store = session?.checkpoints;
2669
+ if (!store) {
2670
+ pushMessage('system', 'This session keeps no file checkpoints.');
2671
+ return;
2672
+ }
2673
+ if (turn === undefined) {
2674
+ pushMessage('system', renderCheckpoints(store.list(), ctxRef.current.cwd));
2675
+ return;
2676
+ }
2677
+ if (abortRef.current || state !== 'idle' || hasUnsettledTurn()) {
2678
+ pushMessage('system', 'A turn is still running. Wait for it, or interrupt it, before restoring files.');
2679
+ return;
2680
+ }
2681
+ try {
2682
+ const report = await store.restore(turn);
2683
+ const text = renderRestore(report, ctxRef.current.cwd);
2684
+ pushMessage('system', text);
2685
+ // The model's picture of those files is now wrong; it reads
2686
+ // this before its next turn.
2687
+ operatorShellRef.current.push(`/restore ${turn}\n${text}`);
2688
+ }
2689
+ catch (err) {
2690
+ pushMessage('system', err instanceof Error ? err.message : String(err));
2691
+ }
2692
+ }, [hasUnsettledTurn, pushMessage, session, state]);
2505
2693
  /** Fork before the selected prompt, then reopen that prompt in the composer. */
2506
2694
  const confirmPromptEdit = useCallback(async (target) => {
2507
2695
  if (editCommittedRef.current || conversationMutationRef.current)
@@ -2536,6 +2724,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2536
2724
  .map((prompt) => prompt.displayText);
2537
2725
  conversationGenRef.current += 1;
2538
2726
  resetSubagentActivity();
2727
+ session?.resetTaskStore?.();
2539
2728
  activeTurnTokenRef.current = null;
2540
2729
  goalActivation.clear();
2541
2730
  wakeGoalDriver();
@@ -2577,6 +2766,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2577
2766
  nextId,
2578
2767
  pushMessage,
2579
2768
  resetSubagentActivity,
2769
+ session,
2580
2770
  resetTranscript,
2581
2771
  wakeGoalDriver,
2582
2772
  ]);
@@ -2654,6 +2844,20 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2654
2844
  st.assistantId = null;
2655
2845
  }
2656
2846
  };
2847
+ if (event.kind !== 'usage' && 'budget' in event && event.budget) {
2848
+ const budget = event.budget;
2849
+ const own = st.lastUsage;
2850
+ setUsage((previous) => ({
2851
+ totalTokens: budget.ownTokens,
2852
+ budget,
2853
+ cost: {
2854
+ ...(own?.cost ?? { totalCost: 0, cacheDiscount: 0, unpricedTokens: 0 }),
2855
+ unpricedTokens: (own?.cost.unpricedTokens ?? 0) +
2856
+ Math.max(0, budget.ownTokens - (own?.totalTokens ?? 0)),
2857
+ },
2858
+ ...(own && previous?.context ? { context: previous.context } : {}),
2859
+ }));
2860
+ }
2657
2861
  switch (event.kind) {
2658
2862
  case 'delta': {
2659
2863
  setState('thinking');
@@ -2668,6 +2872,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2668
2872
  // turn: the wait before the first token is the model's, and
2669
2873
  // counting it would release the opening sentence on arrival.
2670
2874
  st.releasedAt ??= Date.now();
2875
+ setThinking(null);
2671
2876
  st.text += event.text;
2672
2877
  // Held, not appended. Appending each delta is what produced text
2673
2878
  // that types itself out — nothing animates it, but a few
@@ -2697,6 +2902,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2697
2902
  }
2698
2903
  case 'tool-start': {
2699
2904
  closeAssistant();
2905
+ setThinking(null);
2700
2906
  setState('tool');
2701
2907
  const tool = {
2702
2908
  id: event.toolUseId,
@@ -2771,8 +2977,36 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2771
2977
  break;
2772
2978
  }
2773
2979
  case 'usage':
2774
- setUsage({ totalTokens: event.totalTokens, cost: event.cost });
2980
+ st.lastUsage = event;
2981
+ setUsage({
2982
+ totalTokens: event.totalTokens,
2983
+ ...(event.budget ? { budget: event.budget } : {}),
2984
+ cost: event.cost,
2985
+ ...(event.contextTokens !== undefined && event.contextWindowTokens !== undefined
2986
+ ? {
2987
+ context: {
2988
+ tokens: event.contextTokens,
2989
+ windowTokens: event.contextWindowTokens,
2990
+ measured: event.contextMeasuredBy === 'provider',
2991
+ windowAssumed: event.windowSource !== 'provider' && event.windowSource !== 'config',
2992
+ },
2993
+ }
2994
+ : {}),
2995
+ });
2775
2996
  break;
2997
+ case 'reasoning': {
2998
+ if (event.done)
2999
+ break;
3000
+ setState('thinking');
3001
+ // The last line only, bounded: a screen-wide row that changes as
3002
+ // the model writes is a spinner with words, not a transcript.
3003
+ setThinking((prev) => {
3004
+ const joined = `${prev ?? ''}${event.text}`;
3005
+ const lastLine = joined.slice(joined.lastIndexOf('\n') + 1);
3006
+ return lastLine.length > 160 ? `…${lastLine.slice(-159)}` : lastLine;
3007
+ });
3008
+ break;
3009
+ }
2776
3010
  case 'task': {
2777
3011
  // The live list gets every change; the transcript records the
2778
3012
  // opening and the close, as it did before the list existed.
@@ -2801,6 +3035,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2801
3035
  }
2802
3036
  break;
2803
3037
  }
3038
+ case 'job':
3039
+ // The kernel saw it end during a turn; the model reads the notice
3040
+ // on its next tool result, the operator reads this row.
3041
+ pushMessage('system', describeJobExit(event), false, '⚙');
3042
+ break;
2804
3043
  case 'context':
2805
3044
  // Into the TRANSCRIPT, not a status line. A status indicator is
2806
3045
  // present while nothing is happening and gone afterwards, so
@@ -2808,6 +3047,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2808
3047
  // looking at was compacted. The event is a fact about the
2809
3048
  // conversation and belongs in its record.
2810
3049
  pushMessage('system', event.text, false, event.shed ? '⌫' : '⌦');
3050
+ if (event.shed) {
3051
+ setCompactionTally((tally) => ({
3052
+ passes: tally.passes + 1,
3053
+ clearedResults: tally.clearedResults + (event.cleared ?? 0),
3054
+ stubbedNarrations: tally.stubbedNarrations + (event.stubbed ?? 0),
3055
+ summaries: tally.summaries + (event.summarised ? 1 : 0),
3056
+ reclaimedTokens: tally.reclaimedTokens + (event.reclaimedTokens ?? 0),
3057
+ }));
3058
+ }
2811
3059
  break;
2812
3060
  case 'provider-fallback':
2813
3061
  // The transcript, for the reason compaction is in the transcript
@@ -2986,6 +3234,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2986
3234
  const stillHere = () => conversationGenRef.current === turnGeneration;
2987
3235
  let inboxOpen = true;
2988
3236
  const inboxEntries = [];
3237
+ const inputWaiters = new Set();
2989
3238
  const inbox = {
2990
3239
  accept(input) {
2991
3240
  if (!inboxOpen ||
@@ -2996,8 +3245,32 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2996
3245
  return false;
2997
3246
  inboxEntries.push(input);
2998
3247
  setPendingSteers([...inboxEntries]);
3248
+ for (const wake of [...inputWaiters])
3249
+ wake();
2999
3250
  return true;
3000
3251
  },
3252
+ waitForInbound(signal) {
3253
+ if (signal.aborted)
3254
+ return Promise.reject(signal.reason);
3255
+ if (!inboxOpen || inboxEntries.length > 0)
3256
+ return Promise.resolve();
3257
+ return new Promise((resolve, reject) => {
3258
+ const cleanup = () => {
3259
+ inputWaiters.delete(wake);
3260
+ signal.removeEventListener('abort', abort);
3261
+ };
3262
+ const wake = () => {
3263
+ cleanup();
3264
+ resolve();
3265
+ };
3266
+ const abort = () => {
3267
+ cleanup();
3268
+ reject(signal.reason);
3269
+ };
3270
+ inputWaiters.add(wake);
3271
+ signal.addEventListener('abort', abort, { once: true });
3272
+ });
3273
+ },
3001
3274
  drain() {
3002
3275
  if (!inboxOpen ||
3003
3276
  ac.signal.aborted ||
@@ -3025,6 +3298,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3025
3298
  },
3026
3299
  close() {
3027
3300
  inboxOpen = false;
3301
+ for (const wake of [...inputWaiters])
3302
+ wake();
3028
3303
  return inboxEntries.splice(0, inboxEntries.length);
3029
3304
  },
3030
3305
  };
@@ -3094,7 +3369,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3094
3369
  onPermission: askPermission,
3095
3370
  onQuestion: askQuestion,
3096
3371
  inboundMessages: () => inbox.drain(),
3097
- extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
3372
+ waitForInbound: (signal) => inbox.waitForInbound(signal),
3373
+ extraSystem: [composeSkillsPrompt(activeSkills), drainIdleJobNotices(), drainOperatorShell()]
3374
+ .filter((part) => Boolean(part))
3375
+ .join('\n\n') || undefined,
3098
3376
  onConversationMessages: (messages) => {
3099
3377
  // State-only: opaque reasoning/signatures must reach the next
3100
3378
  // provider and durable store without becoming transcript text.
@@ -3312,6 +3590,41 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3312
3590
  return;
3313
3591
  }
3314
3592
  setHistory((prev) => [...prev, value]);
3593
+ // `#` remembers, `!` runs — neither is a prompt. Both are the
3594
+ // operator acting directly, the way other coding agents spell it,
3595
+ // and both leave a row the model reads on its next turn.
3596
+ if (value.startsWith('#') && value.slice(1).trim().length > 0) {
3597
+ const note = value.slice(1).trim();
3598
+ try {
3599
+ const path = appendMemory(note, { scope: 'project', cwd: ctx.cwd });
3600
+ pushMessage('system', `Remembered for this project (${path}): ${note}`);
3601
+ }
3602
+ catch (err) {
3603
+ pushMessage('system', `Could not save memory: ${err instanceof Error ? err.message : String(err)}`);
3604
+ }
3605
+ return;
3606
+ }
3607
+ const escaped = shellEscapeCommand(value);
3608
+ if (escaped !== null) {
3609
+ pushMessage('user', value);
3610
+ const rowId = pushMessage('tool', `! ${escaped}`, true, '…');
3611
+ void runShellEscape(escaped, { cwd: ctx.cwd }).then((result) => {
3612
+ operatorShellRef.current.push(describeShellEscapeForModel(escaped, result));
3613
+ const ok = result.exitCode === 0 && !result.timedOut;
3614
+ const detail = result.output.length > 0 ? result.output.replace(/\n$/u, '').split('\n') : undefined;
3615
+ setMessages((prev) => prev.map((m) => m.id === rowId
3616
+ ? {
3617
+ ...m,
3618
+ content: describeShellEscape(escaped, result),
3619
+ pending: false,
3620
+ glyph: ok ? '✓' : '✗',
3621
+ ...(ok ? {} : { glyphColor: theme.status.error }),
3622
+ ...(detail ? { detail } : {}),
3623
+ }
3624
+ : m));
3625
+ });
3626
+ return;
3627
+ }
3315
3628
  // What actually gets sent. A `prompt` action replaces it with text the
3316
3629
  // command composed, and then takes the ordinary send path below —
3317
3630
  // including the queue — so a command-driven turn is not a second way
@@ -3339,6 +3652,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3339
3652
  description: command.problem
3340
3653
  ? `Unavailable: ${command.problem}`
3341
3654
  : command.description,
3655
+ disabledReason: command.problem,
3342
3656
  })),
3343
3657
  windowSize: suggestionWindowSize(terminal.rows),
3344
3658
  });
@@ -3346,10 +3660,160 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3346
3660
  }
3347
3661
  case 'agent-cockpit': {
3348
3662
  if (!openAgentCockpit()) {
3349
- pushMessage('system', 'No delegated agents are active. When an Agent tool starts one, its live activity appears below the composer and this view can inspect it.');
3663
+ pushMessage('system', `No delegated agents in this conversation.${session?.agentIds.length ? `\nAvailable: ${session.agentIds.join(', ')}` : '\nNo agents are configured.'}`);
3350
3664
  }
3351
3665
  return;
3352
3666
  }
3667
+ case 'settings-picker': {
3668
+ const commands = [
3669
+ {
3670
+ name: 'model',
3671
+ label: 'Model',
3672
+ description: `${session?.providerSummary ?? 'No provider'} · ${session?.modelSummary ?? 'No model'}. Change model or provider.`,
3673
+ },
3674
+ {
3675
+ name: 'effort',
3676
+ label: 'Reasoning effort',
3677
+ description: `${reasoningEffortRef.current ?? session?.reasoningEffortDefault ?? 'Model default'} · applies to future turns in this session.`,
3678
+ ...(session?.reasoningEffortLevels === undefined
3679
+ ? { problem: 'This model does not publish reasoning choices.' }
3680
+ : {}),
3681
+ },
3682
+ {
3683
+ name: 'permissions',
3684
+ label: 'Permissions',
3685
+ description: `${permissionModeLabel(effectivePermissionMode(permissionModeRef.current, session?.approvalLatched() ?? false))} · this session.`,
3686
+ },
3687
+ {
3688
+ name: 'status config',
3689
+ label: 'Configuration',
3690
+ description: 'Show which configuration files and overrides are in use.',
3691
+ },
3692
+ ];
3693
+ setSelectedChoice(0);
3694
+ setChoicePicker({
3695
+ kind: 'command',
3696
+ title: 'Settings',
3697
+ notice: 'Select a setting to view or change it.',
3698
+ values: commands,
3699
+ options: commands.map((command) => ({
3700
+ label: command.label,
3701
+ description: command.description,
3702
+ disabledReason: command.problem,
3703
+ })),
3704
+ windowSize: 7,
3705
+ });
3706
+ return;
3707
+ }
3708
+ case 'goal-editor': {
3709
+ const scope = scopeRef.current;
3710
+ if (!scope || !sessionsRef.current) {
3711
+ pushMessage('system', 'Goals require conversation history to be available.');
3712
+ return;
3713
+ }
3714
+ textPromptTokenRef.current += 1;
3715
+ setTextPrompt({
3716
+ token: textPromptTokenRef.current,
3717
+ kind: slash.edit ? 'goal-edit' : 'goal-create',
3718
+ title: slash.edit ? 'Edit goal' : 'Set a goal',
3719
+ placeholder: `Up to ${goalStatus?.maxGoalRounds ?? DEFAULT_MAX_GOAL_ROUNDS} automatic turns; /goal pause stops continuation.`,
3720
+ emptyNotice: 'Describe the result you want, or press Esc to cancel.',
3721
+ initialValue: slash.edit ? (goalStatus?.objective ?? '') : '',
3722
+ sessionId: scope.sessionId,
3723
+ });
3724
+ return;
3725
+ }
3726
+ case 'goal-picker': {
3727
+ const generation = conversationGenRef.current;
3728
+ const request = {};
3729
+ setSelectedChoice(-1);
3730
+ setChoicePicker({
3731
+ kind: 'command',
3732
+ title: 'Goal',
3733
+ notice: 'Loading goal…',
3734
+ values: [],
3735
+ options: [],
3736
+ windowSize: 7,
3737
+ request,
3738
+ });
3739
+ void (async () => {
3740
+ try {
3741
+ const sessions = sessionsRef.current;
3742
+ const scope = scopeRef.current;
3743
+ if (!sessions || !scope) {
3744
+ setChoicePicker(null);
3745
+ pushMessage('system', 'Goals require conversation history to be available.');
3746
+ return;
3747
+ }
3748
+ const goal = conversationMaterializedRef.current
3749
+ ? await sessions.goals.getGoal(scope.sessionId, sessions.tenantId)
3750
+ : null;
3751
+ if (appLifetime.signal.aborted ||
3752
+ conversationGenRef.current !== generation ||
3753
+ choicePickerRef.current?.request !== request)
3754
+ return;
3755
+ setGoalStatus(goal);
3756
+ const commands = goal
3757
+ ? [
3758
+ {
3759
+ name: 'goal status',
3760
+ description: 'View goal progress and continuation status.',
3761
+ },
3762
+ { name: 'goal edit', description: 'Change the objective.' },
3763
+ ...(goal.phase === 'complete'
3764
+ ? []
3765
+ : [
3766
+ goal.phase === 'active' && goalActivation.isArmed(scope.sessionId, goal)
3767
+ ? {
3768
+ name: 'goal pause',
3769
+ description: 'Stop automatic continuation after the current turn.',
3770
+ }
3771
+ : {
3772
+ name: 'goal resume',
3773
+ description: 'Continue working automatically toward this goal.',
3774
+ },
3775
+ ]),
3776
+ {
3777
+ name: 'goal clear',
3778
+ description: 'Remove this goal and stop its automatic continuation.',
3779
+ },
3780
+ ]
3781
+ : [
3782
+ {
3783
+ name: 'goal set',
3784
+ description: 'Set an objective and start automatic continuation.',
3785
+ },
3786
+ ];
3787
+ setSelectedChoice(0);
3788
+ setChoicePicker({
3789
+ kind: 'command',
3790
+ back: choicePickerRef.current?.back,
3791
+ title: 'Goal',
3792
+ notice: goal
3793
+ ? `${goal.phase} · ${goal.roundsAdmitted}/${goal.maxGoalRounds} automatic turns · ${goal.objective}`
3794
+ : `No goal set for this conversation. Up to ${DEFAULT_MAX_GOAL_ROUNDS} automatic turns when started.`,
3795
+ values: commands,
3796
+ options: commands.map((command) => ({
3797
+ label: `/${command.name}`,
3798
+ description: command.description,
3799
+ })),
3800
+ windowSize: 7,
3801
+ });
3802
+ }
3803
+ catch (error) {
3804
+ if (!appLifetime.signal.aborted &&
3805
+ conversationGenRef.current === generation &&
3806
+ choicePickerRef.current?.request === request) {
3807
+ setChoicePicker(null);
3808
+ pushMessage('system', `Could not load goal: ${error instanceof Error ? error.message : String(error)}`);
3809
+ }
3810
+ }
3811
+ finally {
3812
+ wakeGoalDriver();
3813
+ }
3814
+ })();
3815
+ return;
3816
+ }
3353
3817
  case 'clear-screen':
3354
3818
  setMessages([]);
3355
3819
  resetTranscript();
@@ -3419,7 +3883,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3419
3883
  setKeyEntryFor(null);
3420
3884
  setPickerDetected(null);
3421
3885
  setPickerSelectionKind('provider-and-model');
3422
- setPickerInitialView('providers');
3886
+ setPickerInitialView('models');
3423
3887
  setPhase('picker');
3424
3888
  return;
3425
3889
  case 'permission-mode': {
@@ -3428,7 +3892,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3428
3892
  }
3429
3893
  case 'permission-mode-picker': {
3430
3894
  if (!session?.hasProvider) {
3431
- pushMessage('system', 'No active session — pick a provider before changing permissions.');
3895
+ pushMessage('system', 'Choose a model before changing permissions.');
3432
3896
  return;
3433
3897
  }
3434
3898
  if (state !== 'idle' ||
@@ -3438,22 +3902,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3438
3902
  permissionResolveRef.current !== null ||
3439
3903
  compactingRef.current ||
3440
3904
  !session.resetApprovalLatch) {
3441
- pushMessage('system', 'Permission choices are unavailable until the active turn, prompt, compaction, queued work, or embedded-session approval latch can be safely settled.');
3905
+ pushMessage('system', !session.resetApprovalLatch
3906
+ ? 'This session cannot reset approvals. Reconnect before changing permissions.'
3907
+ : 'Finish or stop the current work before changing permissions.');
3442
3908
  return;
3443
3909
  }
3444
- const values = ['prompt', 'accept-edits', 'auto', 'strict', 'plan'];
3445
- setSelectedChoice(Math.max(0, values.indexOf(permissionModeRef.current)));
3446
- setChoicePicker({
3447
- kind: 'permission-mode',
3448
- title: 'Select Permission Mode',
3449
- notice: permissionPickerNotice(session),
3450
- values,
3451
- options: values.map((mode) => ({
3452
- label: mode,
3453
- description: permissionModeDescription(mode),
3454
- current: mode === permissionModeRef.current,
3455
- })),
3456
- });
3910
+ const picker = permissionPicker(permissionModeRef.current, session.approvalLatched());
3911
+ setSelectedChoice(Math.max(0, picker.options.findIndex((option) => option.current)));
3912
+ setChoicePicker(picker);
3457
3913
  return;
3458
3914
  }
3459
3915
  case 'reasoning-effort': {
@@ -3486,6 +3942,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3486
3942
  label: effort ?? 'default',
3487
3943
  description: reasoningEffortDescription(effort),
3488
3944
  current: effort === current,
3945
+ default: effort === undefined,
3489
3946
  })),
3490
3947
  });
3491
3948
  return;
@@ -3527,16 +3984,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3527
3984
  }
3528
3985
  case 'remember':
3529
3986
  try {
3530
- appendMemory(slash.text);
3531
- pushMessage('system', `Remembered: ${slash.text}`);
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}`);
3532
3989
  }
3533
3990
  catch (err) {
3534
3991
  pushMessage('system', `Could not save memory: ${err instanceof Error ? err.message : String(err)}`);
3535
3992
  }
3536
3993
  return;
3537
3994
  case 'show-memory': {
3538
- const mem = composeMemoryPrompt(readMemory());
3539
- pushMessage('system', mem ?? 'Nothing remembered yet. Use /remember <text>, or edit ~/.namzu/MEMORY.md.');
3995
+ pushMessage('system', renderMemoryReport(readMemory(undefined, ctx.cwd), { cwd: ctx.cwd }));
3540
3996
  return;
3541
3997
  }
3542
3998
  case 'list-skills': {
@@ -3595,79 +4051,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3595
4051
  case 'fork':
3596
4052
  void doFork();
3597
4053
  return;
3598
- case 'expand': {
3599
- // Resolved INSIDE the updater, against `prev`.
3600
- //
3601
- // Not against the `messages` this callback captured: rows arrive
3602
- // from `applyEvent` while a turn runs, which is exactly when
3603
- // someone types `/expand`, and a captured array can be a render
3604
- // behind — so "the most recent one" would sometimes mean the one
3605
- // before it, and the command would quietly show the wrong output.
3606
- // `prev` is by definition the latest.
3607
- //
3608
- // And against the ROWS, not a parallel index of them. An earlier
3609
- // draft kept a ref mirroring `{ref, label, lines}` alongside the
3610
- // rows that already hold all three. That is one concept in two
3611
- // shapes with a hand-written copy between them — the geometry
3612
- // `docs/conventions/one-site-is-not-every-site.md` names as the
3613
- // hardest kind to spot, adopted here to solve a staleness problem
3614
- // the updater solves without it.
3615
- //
3616
- // The id is taken outside so both branches use the same one.
3617
- const id = nextId();
3618
- setMessages((prev) => {
3619
- const blocks = prev.filter((m) => m.detailRef !== undefined);
3620
- const say = (content) => ({
3621
- id,
3622
- role: 'system',
3623
- content,
3624
- });
3625
- if (blocks.length === 0) {
3626
- return [
3627
- ...prev,
3628
- say('Nothing to expand yet. Tool output longer than six lines collapses with a "… +N lines · /expand n" hint, and that number is what this takes.'),
3629
- ];
3630
- }
3631
- const block = slash.which === 'last'
3632
- ? blocks[blocks.length - 1]
3633
- : blocks.find((m) => m.detailRef === slash.which);
3634
- if (!block) {
3635
- // Names the range rather than only refusing: the operator's
3636
- // next move is to type a different number, and they should
3637
- // not have to guess which ones exist.
3638
- return [
3639
- ...prev,
3640
- say(`No collapsed output numbered ${slash.which}. This conversation has ${blocks.length === 1
3641
- ? 'one, numbered 1'
3642
- : `${blocks.length}, numbered 1 to ${blocks.length}`}.`),
3643
- ];
3644
- }
3645
- // A NEW row carrying the same lines, flagged to print them all.
3646
- // Not a mutation of the row that collapsed them: that row was
3647
- // handed to <Static>, which emits an item once and calls the
3648
- // render function only for items it has not emitted yet, so
3649
- // nothing decided now can reach it. Appending is the only
3650
- // expansion this architecture has.
3651
- //
3652
- // The lines travel as `detail` rather than flattened into the
3653
- // content so the `▏` rule and the +/- diff colouring survive.
3654
- // An expansion that showed LESS than the collapsed form would
3655
- // be a strange thing to have asked for.
3656
- //
3657
- // It carries no `detailRef` of its own: it is already in full,
3658
- // so there is nothing for a number to offer, and giving it one
3659
- // would let `/expand` produce a third copy of the same output.
3660
- return [
3661
- ...prev,
3662
- {
3663
- id,
3664
- role: 'system',
3665
- content: `${block.content} — in full (${block.detail?.length ?? 0} lines)`,
3666
- glyph: '⤢',
3667
- detail: block.detail,
3668
- detailExpanded: true,
3669
- },
3670
- ];
4054
+ case 'restore':
4055
+ void doRestore(slash.turn);
4056
+ return;
4057
+ case 'add-dir': {
4058
+ const dirs = session?.directories;
4059
+ if (!dirs) {
4060
+ pushMessage('system', 'No session yet; /add-dir works once one has started.');
4061
+ return;
4062
+ }
4063
+ void dirs.add(slash.path).then((result) => {
4064
+ pushMessage('system', result.added
4065
+ ? `Added ${result.path}. The file tools reach it by absolute path from the next turn; a sandboxed run binds it read-write.`
4066
+ : `Not added: ${result.path} — ${result.reason ?? 'refused'}`);
3671
4067
  });
3672
4068
  return;
3673
4069
  }
@@ -3702,9 +4098,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3702
4098
  await materializeConversation();
3703
4099
  const durableSessions = sessionsRef.current;
3704
4100
  const runScope = scopeRef.current;
4101
+ const taskStore = slash.name === 'tasks' ? session?.currentTaskStore?.() : undefined;
4102
+ if (slash.name === 'tasks' && !taskStore) {
4103
+ pushMessage('system', session?.currentTaskStore
4104
+ ? 'No task list is available yet for this conversation.'
4105
+ : 'Task listing is unavailable in this session.');
4106
+ return;
4107
+ }
3705
4108
  const registry = new HostCommandRegistry();
3706
4109
  registry.register(kernelHostCommands({
3707
4110
  allowedAgentIds: session?.agentIds ?? [],
4111
+ ...(taskStore ? { taskStore } : {}),
3708
4112
  ...(durableSessions && runScope && conversationMaterializedRef.current
3709
4113
  ? {
3710
4114
  goal: {
@@ -4004,11 +4408,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4004
4408
  advanceQueueContinuation,
4005
4409
  applyPermissionMode,
4006
4410
  applyReasoningEffort,
4411
+ appLifetime,
4007
4412
  ctx.cwd,
4008
4413
  doResume,
4009
4414
  enqueueQueued,
4010
4415
  exitWithSummary,
4011
4416
  hasUnsettledTurn,
4417
+ goalActivation,
4418
+ goalStatus,
4419
+ hostCommands,
4012
4420
  nextId,
4013
4421
  openAgentCockpit,
4014
4422
  pushMessage,
@@ -4016,6 +4424,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4016
4424
  removeStoredCredential,
4017
4425
  resetTranscript,
4018
4426
  runConversationExport,
4427
+ session,
4019
4428
  setChoicePicker,
4020
4429
  setCopyPicker,
4021
4430
  setReasoningEffort,
@@ -4224,9 +4633,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4224
4633
  pushMessage('system', `⬆ Update available:\n${lines.join('\n')}`);
4225
4634
  });
4226
4635
  }, [phase, ctx.version, pushMessage]);
4227
- const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal, revealAllOnFailure = false) => {
4636
+ const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal, options = {}) => {
4228
4637
  try {
4229
- await hydrateSession(prefs, detectedNow, signal);
4638
+ await hydrateSession(prefs, detectedNow, signal, options.persistSelection);
4230
4639
  }
4231
4640
  catch (err) {
4232
4641
  // A superseded choice no longer owns even its failure message. Its
@@ -4234,7 +4643,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4234
4643
  // choice stays on the picker with the actionable construction error.
4235
4644
  if (signal.aborted)
4236
4645
  return;
4237
- if (revealAllOnFailure) {
4646
+ if (options.revealAllOnFailure) {
4238
4647
  setPickerDetected(null);
4239
4648
  setPickerSelectionKind('provider-and-model');
4240
4649
  setPickerInitialView('providers');
@@ -4259,48 +4668,29 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4259
4668
  setPickerNotice(null);
4260
4669
  // The disposition, not the key. `credential` never reaches a message.
4261
4670
  pushMessage('system', disposition);
4262
- // The SAVED chain when the credential is for the provider that was
4263
- // already chosen, and a fresh one-member chain otherwise. Rebuilding
4264
- // from the id alone would have been a quiet demotion in the one case
4265
- // this routing creates: an operator whose file pins a model, and whose
4266
- // only problem was a missing secret, would have been moved onto the
4267
- // registry default for supplying it.
4671
+ // Supplying a credential changes the primary's access, not unrelated
4672
+ // fallbacks, model pins, delegation or capability preferences.
4268
4673
  const saved = savedPrefsRef.current;
4269
4674
  const prefs = saved && primaryProvider(saved).id === credential.entry.id
4270
4675
  ? saved
4271
- : {
4272
- version: 3,
4273
- providers: [{ id: credential.entry.id }],
4274
- subagents: { active: [] },
4275
- };
4676
+ : selectPrimaryProvider(saved, { id: credential.entry.id });
4276
4677
  void hydrateFromPicker(prefs, next, signal);
4277
4678
  }, [detected, hydrateFromPicker, pushMessage]);
4278
4679
  const handlePickerSubmit = useCallback((selection, signal) => {
4279
- // One member for now. The picker builds a longer chain in a later
4280
- // change; the shape it writes into is already the chain.
4281
- const prefs = {
4282
- version: 3,
4283
- providers: [
4284
- {
4285
- id: selection.provider,
4286
- ...(selection.model !== undefined ? { model: selection.model } : {}),
4287
- },
4288
- ],
4289
- subagents: { active: [] },
4290
- };
4680
+ const prefs = selectPrimaryProvider(savedPrefsRef.current, {
4681
+ id: selection.provider,
4682
+ ...(selection.model !== undefined ? { model: selection.model } : {}),
4683
+ });
4291
4684
  setKeyEntryFor(null);
4292
4685
  setPickerNotice(null);
4293
- try {
4294
- writePreferences(prefs);
4295
- }
4296
- catch (err) {
4297
- pushMessage('system', `Could not save preferences: ${err instanceof Error ? err.message : String(err)}`);
4298
- return;
4299
- }
4300
4686
  // Keep a narrowed signed-in roster stable while construction is pending.
4301
4687
  // Only a real refusal broadens back to the general, sign-in-capable picker.
4302
- void hydrateFromPicker(prefs, detected, signal, true);
4303
- }, [detected, hydrateFromPicker, pushMessage]);
4688
+ void hydrateFromPicker(prefs, detected, signal, {
4689
+ revealAllOnFailure: true,
4690
+ persistSelection: detected.find((provider) => provider.entry.id === selection.provider)?.source.kind !==
4691
+ 'session',
4692
+ });
4693
+ }, [detected, hydrateFromPicker]);
4304
4694
  /**
4305
4695
  * Leaving the picker returns to whatever was on screen before it.
4306
4696
  *
@@ -4330,6 +4720,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4330
4720
  exitWithSummary();
4331
4721
  }, [session, exitWithSummary]);
4332
4722
  useInput((input, key) => {
4723
+ // Startup has refused admission, so there is no draft or active turn to
4724
+ // protect with the ready screen's two-press exit ladder.
4725
+ if (phase === 'unhealthy') {
4726
+ if (key.escape || (key.ctrl && input === 'c'))
4727
+ exitWithSummary();
4728
+ return;
4729
+ }
4333
4730
  // The provider picker owns its own keyboard — arrows, digits, enter,
4334
4731
  // esc and the typed-key screen are all its business. Ctrl+C is not: it
4335
4732
  // is the one key that must work on every screen, and this handler used
@@ -4347,12 +4744,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4347
4744
  exitWithSummary();
4348
4745
  return;
4349
4746
  }
4350
- // Previous-prompt picker owns the keyboard. Esc keeps stepping toward
4351
- // older prompts, matching the second Esc that opened it; q is the cancel.
4747
+ // Previous-prompt picker uses the same cancel key as other selectors.
4352
4748
  if (phase === 'edit') {
4353
4749
  if (editCommittedRef.current)
4354
4750
  return;
4355
- if ((key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
4751
+ if (key.escape || (key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
4356
4752
  setEditList([]);
4357
4753
  setPhase('ready');
4358
4754
  return;
@@ -4361,7 +4757,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4361
4757
  setSelectedEdit((index) => moveSelection(index, editList.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
4362
4758
  return;
4363
4759
  }
4364
- if (key.escape || key.leftArrow || key.upArrow) {
4760
+ if (key.leftArrow || key.upArrow) {
4365
4761
  setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
4366
4762
  return;
4367
4763
  }
@@ -4435,25 +4831,27 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4435
4831
  setPermissionDetailsOpen(!permissionDetailsOpenRef.current);
4436
4832
  return;
4437
4833
  }
4438
- if (permission &&
4439
- (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
4834
+ if (permission && (key.upArrow || key.downArrow)) {
4835
+ const current = permissionChoiceRef.current;
4836
+ const next = key.upArrow ? Math.max(0, current - 1) : Math.min(2, current + 1);
4837
+ setPermissionChoice(next);
4838
+ return;
4839
+ }
4840
+ if (permission && (key.pageUp || key.pageDown || key.home || key.end)) {
4440
4841
  const source = permissionDetailsOpenRef.current
4441
4842
  ? permission.review
4442
4843
  : permission.summary.text;
4443
4844
  const count = permissionReviewRows(source, terminal.columns).length;
4444
- const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
4845
+ const pageRows = permissionReviewPageRows(terminal.rows);
4846
+ const maxOffset = Math.max(0, count - pageRows);
4445
4847
  const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
4446
4848
  const next = key.home
4447
4849
  ? 0
4448
4850
  : key.end
4449
4851
  ? maxOffset
4450
4852
  : key.pageUp
4451
- ? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
4452
- : key.pageDown
4453
- ? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
4454
- : key.upArrow
4455
- ? Math.max(0, current - 1)
4456
- : Math.min(maxOffset, current + 1);
4853
+ ? Math.max(0, current - pageRows)
4854
+ : Math.min(maxOffset, current + pageRows);
4457
4855
  setPermissionReviewOffset(next);
4458
4856
  return;
4459
4857
  }
@@ -4468,18 +4866,21 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4468
4866
  resolvePermission({ kind: 'reject' });
4469
4867
  return;
4470
4868
  }
4471
- // Enter is deliberately NOT an approval. It is the key people press
4472
- // to dismiss a thing that appeared, it is the key already in flight
4473
- // when a turn's follow-up was being typed, and it is named nowhere
4474
- // on this prompt — so reading it as consent approves a tool call the
4475
- // operator never looked at. `y` and `a` are the only approvals, and
4476
- // they must arrive after the prompt has been up long enough to read.
4869
+ // Every approval waits out the settle window: `y`, `a`, a digit and
4870
+ // Enter alike. Enter confirms the answer the cursor is on, which
4871
+ // opens on "Yes" — the prompt an operator knows from elsewhere — so
4872
+ // the window is what stands between an in-flight Enter and a call
4873
+ // the operator never looked at. Refusals above never wait.
4477
4874
  if (!approvalIsDeliberate(permissionOpenedAtRef.current, Date.now()))
4478
4875
  return;
4479
- if (ch === 'y')
4876
+ const numbered = ch === '1' ? 0 : ch === '2' ? 1 : ch === '3' ? 2 : null;
4877
+ const chosen = key.return ? permissionChoiceRef.current : numbered;
4878
+ if (ch === 'y' || chosen === 0)
4480
4879
  resolvePermission({ kind: 'approve' });
4481
- else if (ch === 'a')
4880
+ else if (ch === 'a' || chosen === 1)
4482
4881
  resolvePermission({ kind: 'approve-all' });
4882
+ else if (chosen === 2)
4883
+ resolvePermission({ kind: 'reject' });
4483
4884
  return;
4484
4885
  }
4485
4886
  // The active child count already advertises this key beside Working.
@@ -4502,7 +4903,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4502
4903
  // highlighted child before the list has appeared on screen.
4503
4904
  if (agentSurfaceCommittedRef.current !== agentView)
4504
4905
  return;
4505
- const agents = activeSubagentCohorts(subagentsRef.current);
4906
+ const agents = subagentsRef.current;
4506
4907
  if (agentView.kind === 'cockpit') {
4507
4908
  const phases = agentPhases(agents);
4508
4909
  const phaseIndex = Math.max(0, phases.findIndex((phase) => phase.id === agentView.selectedPhaseId));
@@ -4629,10 +5030,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4629
5030
  // menu. Only the exact object React committed may consume a choice.
4630
5031
  if (choicePickerCommittedRef.current !== picker)
4631
5032
  return;
4632
- const options = picker.options;
5033
+ const searchable = choicePickerSearchable(picker);
5034
+ const options = filterChoiceOptions(picker.options, choiceQueryRef.current);
5035
+ const applyVisibleChoice = (index) => {
5036
+ const option = options[index];
5037
+ if (option)
5038
+ applyChoiceSelection(picker.options.indexOf(option));
5039
+ };
4633
5040
  if (key.escape || (key.ctrl && input === 'c')) {
4634
5041
  reviewChoiceInFlightRef.current = null;
4635
- setChoicePicker(null);
5042
+ setSelectedChoice(0);
5043
+ setChoicePicker(key.escape ? (picker.back ?? null) : null);
4636
5044
  // Esc leaves the question unanswered and the turn running; the
4637
5045
  // tool tells the model so. Ctrl+C is the operator saying stop.
4638
5046
  if (picker.kind === 'user-question') {
@@ -4641,26 +5049,42 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4641
5049
  return;
4642
5050
  }
4643
5051
  if (key.home || key.end || key.pageUp || key.pageDown) {
4644
- const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
4645
- setSelectedChoice((index) => moveSelection(index, options.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page', pageSize));
5052
+ const pageSize = choicePickerWindowSize({
5053
+ rows: terminal.rows,
5054
+ columns: Math.max(1, (terminal.columns ?? 80) - 2),
5055
+ searchable,
5056
+ notice: Boolean(picker.notice),
5057
+ selectedDescription: options.some((option) => Boolean(option.selectedDescription || option.disabledReason)),
5058
+ windowSize: picker.kind === 'command' ? picker.windowSize : undefined,
5059
+ });
5060
+ setSelectedChoice((index) => moveChoiceSelection(options, index, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page', pageSize));
4646
5061
  return;
4647
5062
  }
4648
5063
  if (key.upArrow) {
4649
- setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
5064
+ setSelectedChoice((index) => moveChoiceSelection(options, index, 'previous'));
4650
5065
  return;
4651
5066
  }
4652
5067
  if (key.downArrow) {
4653
- setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
5068
+ setSelectedChoice((index) => moveChoiceSelection(options, index, 'next'));
4654
5069
  return;
4655
5070
  }
4656
5071
  if (key.return) {
4657
- applyChoiceSelection(selectedChoiceRef.current);
5072
+ applyVisibleChoice(selectedChoiceRef.current);
5073
+ return;
5074
+ }
5075
+ if (searchable && !key.ctrl && !key.meta && (key.backspace || key.delete || input)) {
5076
+ const next = key.backspace || key.delete
5077
+ ? eraseLastChoiceGrapheme(choiceQueryRef.current)
5078
+ : choiceQueryRef.current + input.replace(/\p{Cc}/gu, '');
5079
+ choiceQueryRef.current = next;
5080
+ setChoiceQuery(next);
5081
+ setSelectedChoice(moveChoiceSelection(filterChoiceOptions(picker.options, next), 0, 'first'));
4658
5082
  return;
4659
5083
  }
4660
5084
  if (/^[1-9]$/.test(input)) {
4661
5085
  const index = Number(input) - 1;
4662
5086
  if (options[index])
4663
- applyChoiceSelection(index);
5087
+ applyVisibleChoice(index);
4664
5088
  }
4665
5089
  return;
4666
5090
  }
@@ -4739,19 +5163,36 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4739
5163
  // in place and sometimes prints a second copy further down would be two
4740
5164
  // commands wearing one name.
4741
5165
  if (key.ctrl && input === 'o') {
4742
- // Taken outside the updater so both branches use the same one, as
4743
- // `/expand` does.
5166
+ // Taken outside the updater so both branches use the same one.
4744
5167
  const id = nextId();
4745
5168
  setMessages((prev) => {
4746
5169
  const live = prev.filter((m) => !m.pending).slice(settledRef.current);
4747
5170
  const collapsible = live.filter((m) => willCollapse(m.detail));
4748
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
+ }
4749
5187
  return [
4750
5188
  ...prev,
4751
5189
  {
4752
5190
  id,
4753
5191
  role: 'system',
4754
- content: 'Nothing on screen can be expanded in place. Only the last few rows stay redrawable; anything older has been printed to the terminal’s own scrollback, which cannot be rewritten. Use /expand <n> for those — the number is in the hint under each collapsed body, and /expand on its own takes the most recent.',
5192
+ content: `${block.content} — in full (${block.detail?.length ?? 0} lines)`,
5193
+ glyph: '⤢',
5194
+ detail: block.detail,
5195
+ detailExpanded: true,
4755
5196
  },
4756
5197
  ];
4757
5198
  }
@@ -4803,7 +5244,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4803
5244
  ? representedSubagentTools.has(JSON.stringify([tool.runId, tool.id]))
4804
5245
  : representedUnscopedSubagentToolUseIds.has(tool.id)));
4805
5246
  const selectedSubagent = agentSurface
4806
- ? liveSubagents.find((agent) => agent.viewId === agentSurface.selectedId)
5247
+ ? subagents.find((agent) => agent.viewId === agentSurface.selectedId)
4807
5248
  : undefined;
4808
5249
  const lifecycleOwnsViewport = phase === 'trust' ||
4809
5250
  phase === 'resume' ||
@@ -4840,21 +5281,30 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4840
5281
  : agentSurface?.kind === 'transcript'
4841
5282
  ? 'observing agent — esc agents · q parent'
4842
5283
  : textPrompt
4843
- ? 'name editor open — enter save · esc cancel'
5284
+ ? 'editor open · enter apply · esc cancel'
4844
5285
  : choicePicker
4845
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
5286
+ ? choicePickerSearchable(choicePicker)
5287
+ ? 'type to filter · ↑↓ select · enter apply · esc back'
5288
+ : '↑↓ / 1–9 select · enter apply · esc back'
4846
5289
  : copyPicker
4847
5290
  ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
4848
5291
  : goalStatus && phase === 'ready' && state === 'idle'
4849
5292
  ? undefined
4850
5293
  : hintForPhase(phase, state, session?.hasProvider === true, composerHasDraft);
4851
- return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [transcriptOwned || !lifecycleOwnsViewport ? (_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: renderedSettled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, showLive: !lifecycleOwnsViewport, header: transcriptOwned ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) })) : null, phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : 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, { activeTools: visibleActiveTools, working: state === 'thinking' || state === 'tool', interruptible: abortRef.current !== null, animate: stdout.isTTY === true })) : null, agentSurface === null && permission === null ? _jsx(TaskList, { tasks: tasks }) : null, permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, summary: permission.summary, detailsOpen: permissionDetailsOpen, reviewOffset: permissionReviewOffset, columns: terminal.columns })) : 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, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && agentSurface === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
4852
- phase === 'ready' &&
5294
+ 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' &&
5296
+ state !== 'awaiting-permission' &&
5297
+ !compacting &&
5298
+ externalEditorRequest === null &&
4853
5299
  conversationMutation === null &&
4854
5300
  textPrompt === null &&
4855
5301
  choicePicker === null &&
4856
5302
  copyPicker === null &&
4857
- agentSurface === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
5303
+ agentSurface === null, hidden: permission !== null ||
5304
+ textPrompt !== null ||
5305
+ choicePicker !== null ||
5306
+ copyPicker !== null ||
5307
+ agentSurface?.kind === 'transcript', children: [pendingSteers.length > 0 &&
4858
5308
  permission === null &&
4859
5309
  textPrompt === null &&
4860
5310
  choicePicker === null &&
@@ -4868,7 +5318,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4868
5318
  ? queuePause.outcome === 'paused'
4869
5319
  ? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
4870
5320
  : `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
4871
- : 'sending when ready'] }) })) : null, _jsx(Composer, { permissionMode: permissionMode, onCycleMode: cyclePermissionMode, disabled: phase !== 'ready' ||
5321
+ : 'sending when ready'] }) })) : null, _jsx(Composer, { permissionMode: displayedPermissionMode, onCycleMode: cyclePermissionMode, disabled: phase !== 'ready' ||
4872
5322
  state === 'awaiting-permission' ||
4873
5323
  compacting ||
4874
5324
  conversationMutation !== null ||
@@ -4876,29 +5326,26 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4876
5326
  agentSurface !== null, hidden: permission !== null ||
4877
5327
  textPrompt !== null ||
4878
5328
  choicePicker !== null ||
4879
- copyPicker !== null,
5329
+ copyPicker !== null ||
5330
+ agentSurface?.kind === 'transcript',
4880
5331
  // A turn is running, so Esc is the interrupt and not
4881
5332
  // the composer's clear.
4882
- 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, 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: liveSubagents, 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 }) })] }) }));
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) => ({
5334
+ ...command,
5335
+ 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 }) })] }) }));
4883
5337
  }
4884
- function Banner({ version, session, permissionMode, cwd, }) {
4885
- const { columns } = useWindowSize();
4886
- const wide = columns >= NAMZU_WORDMARK_MIN_WIDTH;
4887
- const provider = session?.providerSummary;
4888
- const model = session?.modelSummary;
4889
- const home = process.env.HOME;
4890
- const prettyCwd = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
4891
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, paddingBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [wide ? (_jsx(Box, { flexDirection: "column", marginRight: 2, children: NAMZU_WORDMARK.map((line, i) => (_jsx(Text, { color: NAMZU_WORDMARK_GRADIENT[i], bold: true, children: line }, `wm-${i}`))) })) : (_jsxs(Text, { color: NAMZU_MARK_COLOR, children: [NAMZU_MARK, " "] })), _jsxs(Box, { flexDirection: "column", marginTop: wide ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.text.secondary, children: "Cogitave" }), wide ? null : (_jsxs(Text, { color: NAMZU_MARK_COLOR, bold: true, children: [' ', "Namzu"] })), _jsxs(Text, { color: theme.text.muted, children: [" v", version] })] }), _jsx(Text, { color: theme.text.secondary, children: provider ? `${provider}${model ? ` · ${model}` : ''}` : 'the agent in your terminal' }), _jsx(Text, { color: theme.text.muted, children: prettyCwd })] })] }), permissionMode === 'auto' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.status.error, bold: true, children: "\u26A0 launched in auto permission mode \u2014 undecided tools run without asking until /permissions changes it" }) })) : null] }));
5338
+ /** One line for a background job that ended, for the transcript and the next turn. */
5339
+ function describeJobExit(job) {
5340
+ const outcome = job.status === 'killed'
5341
+ ? 'was stopped'
5342
+ : `exited ${job.exitCode ?? '?'}${job.signal ? ` (${job.signal})` : ''}`;
5343
+ return `background job ${job.jobId} (${job.command}) ${outcome}`;
4892
5344
  }
4893
5345
  function TranscriptFrame({ children }) {
4894
5346
  // Borderless, edge-to-edge — the message glyph gutter provides structure.
4895
5347
  return _jsx(Box, { flexDirection: "column", children: children });
4896
5348
  }
4897
- function ComposerFrame({ focus, hidden = false, children, }) {
4898
- // Input-field look: a rounded rule above and below the composer, no side
4899
- // borders, so the input reads as a field rather than a heavy box.
4900
- return (_jsx(Box, { flexDirection: "column", ...(hidden ? {} : { borderStyle: 'round' }), borderTop: true, borderBottom: true, borderLeft: false, borderRight: false, borderColor: focus ? theme.border.focus : theme.border.default, marginTop: hidden ? 0 : 1, children: children }));
4901
- }
4902
5349
  // Tool call label: the tool name title-cased, then its most identifying
4903
5350
  // argument — `Bash(ls -la)`, `Read(file.ts)`. A bare tool name in a transcript
4904
5351
  // of forty calls says nothing about which one this was.
@@ -4921,7 +5368,7 @@ hasDraft = false) {
4921
5368
  if (phase === 'resume')
4922
5369
  return '↑↓ navigate · enter resume · esc cancel';
4923
5370
  if (phase === 'edit')
4924
- return 'Esc / ← older · → newer · enter fork and edit · q cancel';
5371
+ return '← older · → newer · enter fork and edit · esc cancel';
4925
5372
  if (phase === 'probing')
4926
5373
  return 'discovering providers…';
4927
5374
  // Esc does two different things here depending on how the picker was reached,
@@ -4933,11 +5380,11 @@ hasDraft = false) {
4933
5380
  : '↑↓ navigate · enter accept · esc or Ctrl+C exit';
4934
5381
  }
4935
5382
  if (phase === 'unhealthy')
4936
- return 'Ctrl+C ×2 to exit';
5383
+ return 'esc or Ctrl+C exit';
4937
5384
  // Enter is absent because Enter decides nothing here, deliberately — see the
4938
5385
  // permission gate in the key handler above.
4939
5386
  if (state === 'awaiting-permission')
4940
- return 'y approve · n / esc reject · a approve all';
5387
+ return '↑↓ select · enter confirm · esc decline';
4941
5388
  if (state !== 'idle')
4942
5389
  return hasDraft ? 'enter steer · tab queue · esc interrupt' : '';
4943
5390
  // The composer already points to /help. Keeping the complete key legend in
@@ -4963,29 +5410,38 @@ function goalStatusLabel(goal) {
4963
5410
  function labelOfOption(question, optionId) {
4964
5411
  return question.options.find((option) => option.id === optionId)?.label ?? optionId;
4965
5412
  }
4966
- function permissionModeDescription(mode) {
4967
- switch (mode) {
4968
- case 'prompt':
4969
- return 'Ask before an undecided tool call runs';
4970
- case 'accept-edits':
4971
- return 'Approve file edits and writes without asking; shell and everything else still ask';
4972
- case 'plan':
4973
- return 'Read-only: the agent explores and presents a plan; leave plan mode to carry it out';
4974
- case 'auto':
4975
- return 'Approve undecided calls unless a rule or safety gate refuses';
4976
- case 'strict':
4977
- return 'Refuse undecided calls; explicit allow rules still work';
4978
- }
5413
+ function choicePickerSearchable(picker) {
5414
+ return ['command', 'skill', 'review-branch', 'review-commit'].includes(picker.kind);
4979
5415
  }
4980
- function permissionPickerNotice(session) {
4981
- const notices = [];
4982
- if (session.approvalLatched()) {
4983
- notices.push('Approve all is active; applying any mode revokes it.');
4984
- }
4985
- const exempt = session.promptExemptTools();
4986
- if (exempt.length > 0)
4987
- notices.push(`Never prompted: ${exempt.join(', ')}.`);
4988
- return notices.length > 0 ? notices.join(' ') : undefined;
5416
+ function permissionPicker(mode, approvedAll, more = false) {
5417
+ const current = effectivePermissionMode(mode, approvedAll);
5418
+ const values = more
5419
+ ? ['auto', 'strict', 'rules']
5420
+ : ['prompt', 'accept-edits', 'plan', 'more'];
5421
+ return {
5422
+ kind: 'permission-mode',
5423
+ title: more ? 'More permission options' : 'Permissions',
5424
+ notice: `Current: ${permissionModeLabel(current)} · this session`,
5425
+ values,
5426
+ options: values.map((value) => {
5427
+ if (value === 'more')
5428
+ return {
5429
+ label: 'More options',
5430
+ description: 'Other approval modes and permission rules',
5431
+ current: current === 'auto' || current === 'strict',
5432
+ };
5433
+ if (value === 'rules')
5434
+ return {
5435
+ label: 'View rules',
5436
+ description: 'Show the permission rules in use',
5437
+ };
5438
+ return {
5439
+ label: permissionModeLabel(value),
5440
+ description: permissionModeDescription(value),
5441
+ current: value === current,
5442
+ };
5443
+ }),
5444
+ };
4989
5445
  }
4990
5446
  function reasoningEffortDescription(effort) {
4991
5447
  switch (effort) {