@namzu/cli 14.3.0 → 15.1.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 (149) hide show
  1. package/README.md +117 -17
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +26 -26
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts.map +1 -1
  6. package/dist/commands/acp.js +3 -2
  7. package/dist/commands/acp.js.map +1 -1
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +2 -0
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/login.d.ts +2 -0
  12. package/dist/commands/login.d.ts.map +1 -1
  13. package/dist/commands/login.js +45 -7
  14. package/dist/commands/login.js.map +1 -1
  15. package/dist/commands/run.d.ts.map +1 -1
  16. package/dist/commands/run.js +36 -12
  17. package/dist/commands/run.js.map +1 -1
  18. package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
  19. package/dist/commands/serve.d.ts.map +1 -0
  20. package/dist/commands/{stubs.js → serve.js} +1 -24
  21. package/dist/commands/serve.js.map +1 -0
  22. package/dist/commands/skills.d.ts +19 -0
  23. package/dist/commands/skills.d.ts.map +1 -0
  24. package/dist/commands/skills.js +107 -0
  25. package/dist/commands/skills.js.map +1 -0
  26. package/dist/integrations/mcp/servers.d.ts +7 -0
  27. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  28. package/dist/integrations/mcp/servers.js +33 -8
  29. package/dist/integrations/mcp/servers.js.map +1 -1
  30. package/dist/integrations/sessions/store.d.ts +8 -0
  31. package/dist/integrations/sessions/store.d.ts.map +1 -1
  32. package/dist/integrations/sessions/store.js +15 -0
  33. package/dist/integrations/sessions/store.js.map +1 -1
  34. package/dist/integrations/subagents/runtime.d.ts +2 -0
  35. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  36. package/dist/integrations/subagents/runtime.js +86 -13
  37. package/dist/integrations/subagents/runtime.js.map +1 -1
  38. package/dist/integrations/updates.d.ts.map +1 -1
  39. package/dist/integrations/updates.js +29 -4
  40. package/dist/integrations/updates.js.map +1 -1
  41. package/dist/tui/App.d.ts +10 -1
  42. package/dist/tui/App.d.ts.map +1 -1
  43. package/dist/tui/App.js +1039 -252
  44. package/dist/tui/App.js.map +1 -1
  45. package/dist/tui/ChoicePicker.d.ts +2 -1
  46. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  47. package/dist/tui/ChoicePicker.js +10 -6
  48. package/dist/tui/ChoicePicker.js.map +1 -1
  49. package/dist/tui/Composer.d.ts +19 -2
  50. package/dist/tui/Composer.d.ts.map +1 -1
  51. package/dist/tui/Composer.js +526 -70
  52. package/dist/tui/Composer.js.map +1 -1
  53. package/dist/tui/CopyPicker.d.ts.map +1 -1
  54. package/dist/tui/CopyPicker.js +3 -4
  55. package/dist/tui/CopyPicker.js.map +1 -1
  56. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  57. package/dist/tui/EditPromptPicker.js +3 -4
  58. package/dist/tui/EditPromptPicker.js.map +1 -1
  59. package/dist/tui/Markdown.d.ts +3 -1
  60. package/dist/tui/Markdown.d.ts.map +1 -1
  61. package/dist/tui/Markdown.js +11 -9
  62. package/dist/tui/Markdown.js.map +1 -1
  63. package/dist/tui/PermissionOverlay.d.ts +11 -6
  64. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  65. package/dist/tui/PermissionOverlay.js +13 -20
  66. package/dist/tui/PermissionOverlay.js.map +1 -1
  67. package/dist/tui/Picker.d.ts.map +1 -1
  68. package/dist/tui/Picker.js +47 -11
  69. package/dist/tui/Picker.js.map +1 -1
  70. package/dist/tui/ResumePicker.d.ts.map +1 -1
  71. package/dist/tui/ResumePicker.js +6 -1
  72. package/dist/tui/ResumePicker.js.map +1 -1
  73. package/dist/tui/StatusBar.js +3 -3
  74. package/dist/tui/StatusBar.js.map +1 -1
  75. package/dist/tui/TextPrompt.d.ts +21 -0
  76. package/dist/tui/TextPrompt.d.ts.map +1 -0
  77. package/dist/tui/TextPrompt.js +139 -0
  78. package/dist/tui/TextPrompt.js.map +1 -0
  79. package/dist/tui/Transcript.d.ts +9 -10
  80. package/dist/tui/Transcript.d.ts.map +1 -1
  81. package/dist/tui/Transcript.js +11 -12
  82. package/dist/tui/Transcript.js.map +1 -1
  83. package/dist/tui/agent.d.ts +57 -23
  84. package/dist/tui/agent.d.ts.map +1 -1
  85. package/dist/tui/agent.js +136 -86
  86. package/dist/tui/agent.js.map +1 -1
  87. package/dist/tui/exit-summary.d.ts.map +1 -1
  88. package/dist/tui/exit-summary.js +2 -1
  89. package/dist/tui/exit-summary.js.map +1 -1
  90. package/dist/tui/external-editor.d.ts +25 -0
  91. package/dist/tui/external-editor.d.ts.map +1 -0
  92. package/dist/tui/external-editor.js +169 -0
  93. package/dist/tui/external-editor.js.map +1 -0
  94. package/dist/tui/index.d.ts.map +1 -1
  95. package/dist/tui/index.js +4 -0
  96. package/dist/tui/index.js.map +1 -1
  97. package/dist/tui/live-window.d.ts +17 -11
  98. package/dist/tui/live-window.d.ts.map +1 -1
  99. package/dist/tui/live-window.js +64 -20
  100. package/dist/tui/live-window.js.map +1 -1
  101. package/dist/tui/login-prompt.d.ts +2 -0
  102. package/dist/tui/login-prompt.d.ts.map +1 -1
  103. package/dist/tui/login-prompt.js +8 -1
  104. package/dist/tui/login-prompt.js.map +1 -1
  105. package/dist/tui/mentions.d.ts +19 -0
  106. package/dist/tui/mentions.d.ts.map +1 -1
  107. package/dist/tui/mentions.js +96 -5
  108. package/dist/tui/mentions.js.map +1 -1
  109. package/dist/tui/permission-review.d.ts +59 -0
  110. package/dist/tui/permission-review.d.ts.map +1 -0
  111. package/dist/tui/permission-review.js +219 -0
  112. package/dist/tui/permission-review.js.map +1 -0
  113. package/dist/tui/run-interruption.d.ts +14 -0
  114. package/dist/tui/run-interruption.d.ts.map +1 -0
  115. package/dist/tui/run-interruption.js +67 -0
  116. package/dist/tui/run-interruption.js.map +1 -0
  117. package/dist/tui/selection-window.d.ts +18 -0
  118. package/dist/tui/selection-window.d.ts.map +1 -0
  119. package/dist/tui/selection-window.js +37 -0
  120. package/dist/tui/selection-window.js.map +1 -0
  121. package/dist/tui/slashCommands.d.ts +48 -8
  122. package/dist/tui/slashCommands.d.ts.map +1 -1
  123. package/dist/tui/slashCommands.js +129 -47
  124. package/dist/tui/slashCommands.js.map +1 -1
  125. package/dist/tui/terminal-hyperlinks.d.ts +20 -0
  126. package/dist/tui/terminal-hyperlinks.d.ts.map +1 -0
  127. package/dist/tui/terminal-hyperlinks.js +70 -0
  128. package/dist/tui/terminal-hyperlinks.js.map +1 -0
  129. package/dist/tui/types.d.ts +2 -0
  130. package/dist/tui/types.d.ts.map +1 -1
  131. package/dist/tui/use-selection-index.d.ts +8 -0
  132. package/dist/tui/use-selection-index.d.ts.map +1 -0
  133. package/dist/tui/use-selection-index.js +13 -0
  134. package/dist/tui/use-selection-index.js.map +1 -0
  135. package/dist/tui/workspace-review.d.ts +15 -0
  136. package/dist/tui/workspace-review.d.ts.map +1 -0
  137. package/dist/tui/workspace-review.js +67 -0
  138. package/dist/tui/workspace-review.js.map +1 -0
  139. package/dist/version.d.ts +10 -0
  140. package/dist/version.d.ts.map +1 -0
  141. package/dist/version.js +23 -0
  142. package/dist/version.js.map +1 -0
  143. package/package.json +8 -7
  144. package/dist/commands/stubs.d.ts.map +0 -1
  145. package/dist/commands/stubs.js.map +0 -1
  146. package/dist/tui/bottom-spacer.d.ts +0 -95
  147. package/dist/tui/bottom-spacer.d.ts.map +0 -1
  148. package/dist/tui/bottom-spacer.js +0 -83
  149. package/dist/tui/bottom-spacer.js.map +0 -1
package/dist/tui/App.js CHANGED
@@ -12,16 +12,16 @@ 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 { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
15
+ import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
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';
19
19
  import { visibleProjectInstructionPath } from '../context/project-path.js';
20
20
  import { runtimeContextLabel } from '../context/runtime-message.js';
21
21
  import { writeClipboardText } from '../integrations/clipboard/text.js';
22
22
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
23
- import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
24
- import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
23
+ import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, clearStoredCodexCredential, clearStoredSubscriptionCredential, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
24
+ import { appendMessages, archiveConversation, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, loadResumableConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
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';
@@ -29,32 +29,39 @@ import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.j
29
29
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
30
30
  import { discoverUserCommands } from '../user-commands/store.js';
31
31
  import { ChoicePicker } from './ChoicePicker.js';
32
- import { Composer } from './Composer.js';
32
+ import { Composer, suggestionWindowSize, } from './Composer.js';
33
33
  import { CopyPicker } from './CopyPicker.js';
34
34
  import { EditPromptPicker } from './EditPromptPicker.js';
35
35
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
36
36
  import { PermissionOverlay } from './PermissionOverlay.js';
37
+ import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
37
38
  import { Picker } from './Picker.js';
38
39
  import { ResumePicker } from './ResumePicker.js';
39
40
  import { StatusBar } from './StatusBar.js';
41
+ import { TextPrompt } from './TextPrompt.js';
40
42
  import { Transcript, willCollapse } from './Transcript.js';
43
+ import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
41
44
  import { TrustPrompt } from './TrustPrompt.js';
42
45
  import { createAgentSession, probeAgentSession, } from './agent.js';
43
- import { bottomSpacerRows } from './bottom-spacer.js';
44
46
  import { keepRecentRows } from './compact-transcript.js';
45
47
  import { approvalIsDeliberate } from './consent-timing.js';
46
48
  import { planTurnPublication } from './conversation-history.js';
47
49
  import { copyTargetsForResponse } from './copy-targets.js';
48
50
  import { editablePrompts } from './edit-prompts.js';
49
- import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
50
- import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
51
+ import { liveWindow } from './live-window.js';
52
+ import { describeRunInterruption } from './run-interruption.js';
53
+ import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
51
54
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
52
- import { expandFileMentions } from './mentions.js';
55
+ import { expandFileMentions, listMentionableFiles } from './mentions.js';
53
56
  import { openInBrowser } from './open-browser.js';
54
- import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
57
+ import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
55
58
  import { splitCompleteBlocks } from './stream-blocks.js';
59
+ import { moveSelection } from './selection-window.js';
56
60
  import { theme } from './theme.js';
61
+ import { useSelectionIndex } from './use-selection-index.js';
62
+ import { editDraftInExternalEditor } from './external-editor.js';
57
63
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
64
+ import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
58
65
  /** Last non-empty assistant text in a durable conversation, newest first. */
59
66
  function latestAssistantOutput(messages) {
60
67
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -67,6 +74,12 @@ function latestAssistantOutput(messages) {
67
74
  }
68
75
  return null;
69
76
  }
77
+ /** Exact operator-authored prompts that belong in terminal input history. */
78
+ export function promptHistoryFromConversation(messages) {
79
+ return messages.flatMap((message) => message.role === 'user' && !message.source && message.content.trim().length > 0
80
+ ? [message.content]
81
+ : []);
82
+ }
70
83
  /** Build the transcript projection of exact model-visible history. */
71
84
  export function projectConversation(messages, nextId, readableUserTexts = []) {
72
85
  let userOrdinal = 0;
@@ -138,22 +151,6 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
138
151
  return [];
139
152
  });
140
153
  }
141
- function sameSpacerLayout(previous, current) {
142
- if (previous === null ||
143
- previous.rows !== current.rows ||
144
- previous.columns !== current.columns ||
145
- previous.raw !== current.raw ||
146
- previous.messageCount !== current.messageCount ||
147
- previous.tail.length !== current.tail.length)
148
- return false;
149
- return current.tail.every((message, index) => {
150
- const prior = previous.tail[index];
151
- return (prior?.id === message.id &&
152
- prior.content === message.content &&
153
- prior.meta === message.meta &&
154
- prior.detail === message.detail);
155
- });
156
- }
157
154
  /** Put an undelivered steer back where it occurred among Tab-queued prompts. */
158
155
  function mergeUndeliveredLiveInput(queued, undelivered) {
159
156
  const merged = [...queued];
@@ -196,13 +193,13 @@ function goalRoundPrompt(authority) {
196
193
  * Rows the live region occupies apart from the transcript window: the activity
197
194
  * line, the composer frame and its padding, the status bar.
198
195
  *
199
- * Counted generously, because over-counting costs a gap under the composer and
200
- * under-counting costs the composer itself — and, now that a window of live
201
- * rows sits above this furniture, under-counting also lets that window grow
202
- * until the renderer gives up on incremental repaint. See `live-window.ts`.
196
+ * Counted generously because under-counting lets the redrawable transcript
197
+ * window grow until the renderer gives up on incremental repaint. Over-counting
198
+ * merely settles one more row into native terminal scrollback.
203
199
  */
204
200
  const LIVE_FURNITURE_ROWS = 10;
205
- export function App({ ctx: initialCtx, onExitSummary }) {
201
+ const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
202
+ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
206
203
  // Bind approval and every operation it admits to one real directory. A
207
204
  // lexical cwd may be a writable symlink; resolving it again after the gate
208
205
  // would let it be repointed from the approved project to another.
@@ -217,6 +214,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
217
214
  }, [initialCtx]);
218
215
  const { exit } = useApp();
219
216
  const { stdout, write: writeStdout } = useStdout();
217
+ const terminal = useWindowSize();
218
+ const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
220
219
  /**
221
220
  * The last assistant message id the run reported, for `/feedback`.
222
221
  *
@@ -251,6 +250,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
251
250
  * has to pretend it is one.
252
251
  */
253
252
  const modelHistoryRef = useRef([]);
253
+ /** Consumed only after the exact durable conversation has loaded successfully. */
254
+ const initialConversationIdRef = useRef(initialCtx.initialConversationId);
254
255
  const [history, setHistory] = useState([]);
255
256
  const [state, setState] = useState('idle');
256
257
  const [phase, setPhase] = useState('probing');
@@ -284,6 +285,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
284
285
  */
285
286
  const [pickerNotice, setPickerNotice] = useState(null);
286
287
  const [permission, setPermission] = useState(null);
288
+ const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
289
+ const permissionReviewOffsetRef = useRef(0);
290
+ const setPermissionReviewOffset = useCallback((next) => {
291
+ permissionReviewOffsetRef.current = next;
292
+ setPermissionReviewOffsetState(next);
293
+ }, []);
287
294
  // The launch bypass is an INITIAL selection, not permanent authority. A
288
295
  // typed /permissions command can narrow it back to prompt/strict without
289
296
  // rebuilding the provider, tools, plugins, or sandbox.
@@ -302,6 +309,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
302
309
  // per keypress is a cost nobody asked for. A file added mid-session is
303
310
  // picked up by `/model` (which re-hydrates) or a restart.
304
311
  const [userCommands, setUserCommands] = useState([]);
312
+ /** Git-admitted file paths cached once per successful session hydration. */
313
+ const [mentionCandidates, setMentionCandidates] = useState([]);
314
+ const mentionLoadOwnerRef = useRef(null);
305
315
  const [usage, setUsage] = useState(null);
306
316
  // Context fill, straight from the kernel and held apart from `usage` —
307
317
  // they are different quantities and conflating them is what made the
@@ -321,8 +331,6 @@ export function App({ ctx: initialCtx, onExitSummary }) {
321
331
  * hold the window shut for the length of the next conversation.
322
332
  */
323
333
  const settledRef = useRef(0);
324
- /** Keeps an in-place detail toggle from moving the rows above that detail. */
325
- const spacerLayoutRef = useRef(null);
326
334
  // Complete prompts waiting for the one queue pump that may start a turn.
327
335
  const [queued, setQueued] = useState([]);
328
336
  /** Return-submitted prompts accepted by the active turn but not yet drained by the SDK. */
@@ -371,14 +379,14 @@ export function App({ ctx: initialCtx, onExitSummary }) {
371
379
  const compactingRef = useRef(false);
372
380
  const [compacting, setCompacting] = useState(false);
373
381
  const [resumeList, setResumeList] = useState([]);
374
- const [selectedResume, setSelectedResume] = useState(0);
382
+ const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
375
383
  const [editList, setEditList] = useState([]);
376
- const [selectedEdit, setSelectedEdit] = useState(0);
384
+ const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
377
385
  const editCommittedRef = useRef(false);
378
386
  /** `/copy` owns this exact response snapshot until selection or cancellation. */
379
387
  const [copyPicker, setCopyPickerState] = useState(null);
380
388
  const copyPickerRef = useRef(null);
381
- const [selectedCopy, setSelectedCopy] = useState(0);
389
+ const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
382
390
  const setCopyPicker = useCallback((next) => {
383
391
  copyPickerRef.current = next;
384
392
  setCopyPickerState(next);
@@ -392,7 +400,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
392
400
  * that created it before a menu has existed in the rendered tree.
393
401
  */
394
402
  const choicePickerCommittedRef = useRef(null);
395
- const [selectedChoice, setSelectedChoice] = useState(0);
403
+ const commandPickerSubmitRef = useRef(() => { });
404
+ const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
396
405
  const setChoicePicker = useCallback((next) => {
397
406
  choicePickerRef.current = next;
398
407
  if (next === null)
@@ -402,8 +411,18 @@ export function App({ ctx: initialCtx, onExitSummary }) {
402
411
  useEffect(() => {
403
412
  choicePickerCommittedRef.current = choicePicker;
404
413
  }, [choicePicker]);
414
+ /** Host-owned text decision; its value never enters model prompt history. */
415
+ const [textPrompt, setTextPromptState] = useState(null);
416
+ const textPromptRef = useRef(null);
417
+ const textPromptTokenRef = useRef(0);
418
+ const setTextPrompt = useCallback((next) => {
419
+ textPromptRef.current = next;
420
+ setTextPromptState(next);
421
+ }, []);
405
422
  const [composerDraft, setComposerDraft] = useState(null);
406
423
  const composerDraftTokenRef = useRef(0);
424
+ /** One git-backed review choice may be resolving while its visible picker remains authoritative. */
425
+ const reviewChoiceInFlightRef = useRef(null);
407
426
  const exitArmedRef = useRef(false);
408
427
  const abortRef = useRef(null);
409
428
  /** Identity of the turn allowed to notify when it settles. */
@@ -533,6 +552,10 @@ export function App({ ctx: initialCtx, onExitSummary }) {
533
552
  */
534
553
  const conversationMutationRef = useRef(null);
535
554
  const [conversationMutation, setConversationMutation] = useState(null);
555
+ /** Host editor operation; it owns terminal input until its child settles. */
556
+ const [externalEditorRequest, setExternalEditorRequest] = useState(null);
557
+ const externalEditorRequestRef = useRef(null);
558
+ const externalEditorStartedRef = useRef(null);
536
559
  /** Closes the same-tick input window while a verified export reads disk. */
537
560
  const exportingRef = useRef(false);
538
561
  /** A goal mutation is ordered before any later conversation command. */
@@ -568,6 +591,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
568
591
  settledRef.current = 0;
569
592
  setResetKey((k) => k + 1);
570
593
  }, [stdout.isTTY, writeStdout]);
594
+ const requestExternalEditor = useCallback((seed) => {
595
+ if (phase !== 'ready' ||
596
+ state !== 'idle' ||
597
+ abortRef.current !== null ||
598
+ hasUnsettledTurn() ||
599
+ queuedRef.current.length > 0 ||
600
+ compactingRef.current ||
601
+ conversationMutationRef.current !== null) {
602
+ return Promise.reject(new Error('wait for the active turn, queue, compaction, or conversation change to settle'));
603
+ }
604
+ if (externalEditorRequestRef.current) {
605
+ return Promise.reject(new Error('an external editor is already open'));
606
+ }
607
+ return new Promise((resolve, reject) => {
608
+ const request = {
609
+ token: {},
610
+ seed,
611
+ controller: new AbortController(),
612
+ resolve,
613
+ reject,
614
+ };
615
+ externalEditorRequestRef.current = request;
616
+ setExternalEditorRequest(request);
617
+ });
618
+ }, [hasUnsettledTurn, phase, state]);
619
+ useEffect(() => {
620
+ const request = externalEditorRequest;
621
+ if (!request || externalEditorStartedRef.current === request.token)
622
+ return;
623
+ externalEditorStartedRef.current = request.token;
624
+ void (async () => {
625
+ // Let React deactivate every Ink input hook first. The editor must inherit
626
+ // a cooked terminal, not the raw byte stream the composer consumes.
627
+ await new Promise((resolve) => setImmediate(resolve));
628
+ if (request.controller.signal.aborted)
629
+ return;
630
+ if (stdout.isTTY)
631
+ writeStdout('\x1b[2J\x1b[H');
632
+ let edited;
633
+ let failure;
634
+ try {
635
+ edited = await externalEditor({
636
+ seed: request.seed,
637
+ cwd: trustedCwdRef.current,
638
+ signal: request.controller.signal,
639
+ });
640
+ }
641
+ catch (error) {
642
+ failure = error;
643
+ }
644
+ if (request.controller.signal.aborted)
645
+ return;
646
+ if (externalEditorRequestRef.current === request) {
647
+ externalEditorRequestRef.current = null;
648
+ resetTranscript();
649
+ setExternalEditorRequest(null);
650
+ }
651
+ if (failure !== undefined)
652
+ request.reject(failure);
653
+ else
654
+ request.resolve(edited ?? '');
655
+ })();
656
+ }, [externalEditor, externalEditorRequest, resetTranscript, stdout.isTTY, writeStdout]);
657
+ useEffect(() => () => {
658
+ const request = externalEditorRequestRef.current;
659
+ if (!request)
660
+ return;
661
+ externalEditorRequestRef.current = null;
662
+ request.controller.abort(new Error('the terminal session closed'));
663
+ }, []);
571
664
  const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
572
665
  const id = nextId();
573
666
  setMessages((prev) => [
@@ -650,6 +743,222 @@ export function App({ ctx: initialCtx, onExitSummary }) {
650
743
  setReasoningEffort(effort);
651
744
  pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
652
745
  }, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
746
+ const stepReasoningEffort = useCallback((direction) => {
747
+ const levels = session?.reasoningEffortLevels;
748
+ if (!session?.hasProvider || levels === undefined || levels.length === 0) {
749
+ pushMessage('system', 'Reasoning shortcuts are unavailable because the current provider chain has no exact non-empty effort menu.');
750
+ return;
751
+ }
752
+ const selected = reasoningEffortRef.current;
753
+ const anchor = selected !== undefined && levels.includes(selected)
754
+ ? selected
755
+ : session.reasoningEffortDefault;
756
+ if (anchor === undefined || !levels.includes(anchor)) {
757
+ pushMessage('system', 'Reasoning shortcuts are unavailable because the provider chain does not publish one exact default. Use /effort to choose a level explicitly.');
758
+ return;
759
+ }
760
+ const index = levels.indexOf(anchor);
761
+ const next = direction === 'raise' ? levels[index + 1] : levels[index - 1];
762
+ if (next === undefined) {
763
+ pushMessage('system', `Reasoning is already at the ${direction === 'raise' ? 'highest' : 'lowest'} shortcut level (${anchor}).`);
764
+ return;
765
+ }
766
+ if (direction === 'raise' && (next === 'max' || next === 'ultra')) {
767
+ pushMessage('system', `${next} is an advanced reasoning level. Choose it explicitly with /effort.`);
768
+ return;
769
+ }
770
+ applyReasoningEffort(next);
771
+ }, [applyReasoningEffort, pushMessage, session]);
772
+ const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
773
+ // Written under the same `<cwd>/.namzu` root the runs live in, so a
774
+ // rating and the transcript it judges travel together.
775
+ const store = new DiskMessageFeedbackStore({
776
+ rootDir: join(ctx.cwd, '.namzu', 'feedback'),
777
+ runsDir: join(ctx.cwd, '.namzu', 'runs'),
778
+ });
779
+ const runId = runIdValue;
780
+ const messageId = messageIdValue;
781
+ void (async () => {
782
+ try {
783
+ // A later rating replaces the first. Read-then-write preserves the
784
+ // store's owner-version collision check instead of blind overwrite.
785
+ const current = (await store.listMessageFeedback({ runId })).find((record) => record.messageId === messageId);
786
+ const record = await store.putMessageFeedback({
787
+ runId,
788
+ messageId,
789
+ rating,
790
+ ...(note ? { note } : {}),
791
+ expectedVersion: current?.ownerVersion ?? 0,
792
+ });
793
+ pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
794
+ }
795
+ catch (error) {
796
+ pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
797
+ }
798
+ })();
799
+ }, [ctx.cwd, pushMessage]);
800
+ const activateSkill = useCallback((name) => {
801
+ const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
802
+ if (!info) {
803
+ pushMessage('system', `No skill named "${name}". See /skills.`);
804
+ return;
805
+ }
806
+ try {
807
+ const body = loadSkillBody(info);
808
+ setActiveSkills((previous) => [
809
+ ...previous.filter((skill) => skill.name !== info.name),
810
+ { name: info.name, body },
811
+ ]);
812
+ pushMessage('system', `Activated skill: ${info.name}`);
813
+ }
814
+ catch (error) {
815
+ pushMessage('system', `Could not load skill "${name}": ${error instanceof Error ? error.message : String(error)}`);
816
+ }
817
+ }, [ctx.cwd, pushMessage]);
818
+ const removeStoredCredential = useCallback((target) => {
819
+ const path = credentialsPath();
820
+ const hadClaude = readStoredSubscriptionCredential() !== null;
821
+ const hadCodex = readStoredCodexCredential() !== null;
822
+ try {
823
+ if (target === 'anthropic')
824
+ clearStoredSubscriptionCredential();
825
+ else if (target === 'codex')
826
+ clearStoredCodexCredential();
827
+ else
828
+ clearAllStoredCredentials();
829
+ }
830
+ catch (error) {
831
+ pushMessage('system', `Could not remove ${path}: ${error instanceof Error ? error.message : String(error)}`);
832
+ return;
833
+ }
834
+ if (target === 'all') {
835
+ pushMessage('system', describeLogout(path, hadClaude || hadCodex));
836
+ return;
837
+ }
838
+ pushMessage('system', describeProviderLogout(path, target, target === 'anthropic' ? hadClaude : hadCodex));
839
+ }, [pushMessage]);
840
+ /** Publish one recoverable, read-only tombstone before leaving the TUI. */
841
+ const archiveCurrentConversation = useCallback(() => {
842
+ if (conversationMutationRef.current)
843
+ return;
844
+ const sessions = sessionsRef.current;
845
+ const sessionId = scopeRef.current?.sessionId;
846
+ if (!sessions || !sessionId) {
847
+ pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
848
+ return;
849
+ }
850
+ if (state !== 'idle' ||
851
+ abortRef.current !== null ||
852
+ hasUnsettledTurn() ||
853
+ queuedRef.current.length > 0 ||
854
+ compactingRef.current ||
855
+ exportingRef.current) {
856
+ pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
857
+ return;
858
+ }
859
+ const generation = conversationGenRef.current;
860
+ conversationMutationRef.current = 'archive';
861
+ setConversationMutation('archive');
862
+ void (async () => {
863
+ let archived = false;
864
+ try {
865
+ await persistenceTailRef.current;
866
+ if (conversationGenRef.current !== generation ||
867
+ scopeRef.current?.sessionId !== sessionId ||
868
+ hasUnsettledTurn(generation) ||
869
+ queuedRef.current.length > 0) {
870
+ throw new Error('the active conversation changed before archive publication');
871
+ }
872
+ await archiveConversation(sessions, sessionId);
873
+ archived = true;
874
+ goalActivation.clear();
875
+ onExitSummary?.({});
876
+ exit();
877
+ }
878
+ catch (error) {
879
+ pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
880
+ }
881
+ finally {
882
+ if (!archived && conversationMutationRef.current === 'archive') {
883
+ conversationMutationRef.current = null;
884
+ setConversationMutation(null);
885
+ }
886
+ }
887
+ })();
888
+ }, [exit, goalActivation, hasUnsettledTurn, onExitSummary, pushMessage, state]);
889
+ /**
890
+ * Resolve the one durable conversation boundary shared by both export
891
+ * destinations. The chooser itself owns input, but it does not weaken this
892
+ * admission check: a direct `/export path` and a later chooser selection
893
+ * must prove the same session and turn stability before reading evidence.
894
+ */
895
+ const stableExportSource = useCallback(() => {
896
+ const sessions = sessionsRef.current;
897
+ const sessionId = scopeRef.current?.sessionId;
898
+ if (!sessions || !sessionId) {
899
+ pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
900
+ return null;
901
+ }
902
+ if (abortRef.current ||
903
+ state !== 'idle' ||
904
+ hasUnsettledTurn() ||
905
+ compactingRef.current ||
906
+ queuedRef.current.length > 0 ||
907
+ exportingRef.current) {
908
+ pushMessage('system', 'A turn, compaction, queued prompt, or export is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
909
+ return null;
910
+ }
911
+ return { sessions, sessionId };
912
+ }, [hasUnsettledTurn, pushMessage, state]);
913
+ const runConversationExport = useCallback((destination) => {
914
+ const source = stableExportSource();
915
+ if (!source)
916
+ return;
917
+ exportingRef.current = true;
918
+ setState('thinking');
919
+ const generation = conversationGenRef.current;
920
+ void (async () => {
921
+ try {
922
+ await persistenceTailRef.current;
923
+ if (conversationGenRef.current !== generation ||
924
+ scopeRef.current?.sessionId !== source.sessionId) {
925
+ throw new Error('The active conversation changed before the export reached its durable boundary.');
926
+ }
927
+ const projected = await conversationMarkdown(source.sessions, source.sessionId);
928
+ if (destination.kind === 'file') {
929
+ const written = await writeConversationExport(projected.markdown, destination.path, ctx.cwd);
930
+ pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
931
+ return;
932
+ }
933
+ const result = writeClipboardText(projected.markdown, {
934
+ isTTY: stdout.isTTY,
935
+ write: writeStdout,
936
+ });
937
+ switch (result.kind) {
938
+ case 'request-sent':
939
+ pushMessage('system', `Export copy request sent for ${projected.turns} verified turn${projected.turns === 1 ? '' : 's'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
940
+ break;
941
+ case 'unavailable':
942
+ pushMessage('system', `Cannot send the verified export to the clipboard here — ${result.detail}. Choose Save to file instead.`);
943
+ break;
944
+ case 'too-large':
945
+ pushMessage('system', `Cannot send the verified export to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated; choose Save to file instead.`);
946
+ break;
947
+ case 'write-failed':
948
+ pushMessage('system', `Could not send the verified export copy request: ${result.detail}`);
949
+ break;
950
+ }
951
+ }
952
+ catch (err) {
953
+ pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
954
+ }
955
+ finally {
956
+ exportingRef.current = false;
957
+ if (conversationGenRef.current === generation)
958
+ setState('idle');
959
+ }
960
+ })();
961
+ }, [ctx.cwd, pushMessage, stableExportSource, stdout.isTTY, writeStdout]);
653
962
  const applyChoiceSelection = useCallback((index) => {
654
963
  const picker = choicePickerRef.current;
655
964
  if (!picker)
@@ -657,13 +966,211 @@ export function App({ ctx: initialCtx, onExitSummary }) {
657
966
  const value = picker.values[index];
658
967
  if (index < 0 || index >= picker.values.length)
659
968
  return;
969
+ if (picker.kind === 'review-preset') {
970
+ if (reviewChoiceInFlightRef.current)
971
+ return;
972
+ if (value === 'custom') {
973
+ setChoicePicker(null);
974
+ composerDraftTokenRef.current += 1;
975
+ setComposerDraft({
976
+ token: composerDraftTokenRef.current,
977
+ text: '/review ',
978
+ });
979
+ return;
980
+ }
981
+ const operation = {};
982
+ reviewChoiceInFlightRef.current = operation;
983
+ void (async () => {
984
+ try {
985
+ if (value === 'base-branch') {
986
+ const listing = await listReviewBranches(ctx.cwd);
987
+ if (appLifetime.signal.aborted ||
988
+ reviewChoiceInFlightRef.current !== operation ||
989
+ choicePickerRef.current !== picker)
990
+ return;
991
+ if (!listing || listing.branches.length === 0) {
992
+ setChoicePicker(null);
993
+ pushMessage('system', 'No local branch is available for comparison in this working directory.');
994
+ return;
995
+ }
996
+ setSelectedChoice(0);
997
+ setChoicePicker({
998
+ kind: 'review-branch',
999
+ title: 'Select a base branch',
1000
+ notice: `Current branch: ${listing.current}`,
1001
+ values: listing.branches,
1002
+ options: listing.branches.map((branch) => ({
1003
+ label: branch,
1004
+ description: `${listing.current} → ${branch}`,
1005
+ })),
1006
+ });
1007
+ return;
1008
+ }
1009
+ if (value === 'commit') {
1010
+ const commits = await listReviewCommits(ctx.cwd);
1011
+ if (appLifetime.signal.aborted ||
1012
+ reviewChoiceInFlightRef.current !== operation ||
1013
+ choicePickerRef.current !== picker)
1014
+ return;
1015
+ if (!commits || commits.length === 0) {
1016
+ setChoicePicker(null);
1017
+ pushMessage('system', 'No commit is available to review in this working directory.');
1018
+ return;
1019
+ }
1020
+ setSelectedChoice(0);
1021
+ setChoicePicker({
1022
+ kind: 'review-commit',
1023
+ title: 'Select a commit to review',
1024
+ values: commits,
1025
+ options: commits.map((commit) => ({
1026
+ label: commit.title,
1027
+ description: commit.sha.slice(0, 12),
1028
+ })),
1029
+ });
1030
+ return;
1031
+ }
1032
+ const diff = await workspaceDiff(ctx.cwd);
1033
+ if (appLifetime.signal.aborted ||
1034
+ reviewChoiceInFlightRef.current !== operation ||
1035
+ choicePickerRef.current !== picker)
1036
+ return;
1037
+ if (diff === null) {
1038
+ setChoicePicker(null);
1039
+ pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
1040
+ return;
1041
+ }
1042
+ if (diff.stat.length === 0 && diff.untracked.length === 0) {
1043
+ setChoicePicker(null);
1044
+ pushMessage('system', 'Nothing to review — the working tree is clean.');
1045
+ return;
1046
+ }
1047
+ advanceQueueContinuation();
1048
+ enqueueQueued({
1049
+ kind: 'human',
1050
+ text: reviewPrompt(diff.stat, diff.untracked),
1051
+ });
1052
+ setChoicePicker(null);
1053
+ }
1054
+ finally {
1055
+ if (reviewChoiceInFlightRef.current === operation) {
1056
+ reviewChoiceInFlightRef.current = null;
1057
+ }
1058
+ }
1059
+ })();
1060
+ return;
1061
+ }
1062
+ if (picker.kind === 'review-branch') {
1063
+ if (reviewChoiceInFlightRef.current)
1064
+ return;
1065
+ const branch = value;
1066
+ const operation = {};
1067
+ reviewChoiceInFlightRef.current = operation;
1068
+ void (async () => {
1069
+ try {
1070
+ const mergeBase = await reviewMergeBase(ctx.cwd, branch);
1071
+ if (appLifetime.signal.aborted ||
1072
+ reviewChoiceInFlightRef.current !== operation ||
1073
+ choicePickerRef.current !== picker)
1074
+ return;
1075
+ if (!mergeBase) {
1076
+ setChoicePicker(null);
1077
+ pushMessage('system', `Could not resolve a merge base for ${branch}.`);
1078
+ return;
1079
+ }
1080
+ advanceQueueContinuation();
1081
+ enqueueQueued({
1082
+ kind: 'human',
1083
+ text: baseBranchReviewPrompt(mergeBase),
1084
+ });
1085
+ setChoicePicker(null);
1086
+ }
1087
+ finally {
1088
+ if (reviewChoiceInFlightRef.current === operation) {
1089
+ reviewChoiceInFlightRef.current = null;
1090
+ }
1091
+ }
1092
+ })();
1093
+ return;
1094
+ }
1095
+ if (picker.kind === 'review-commit') {
1096
+ advanceQueueContinuation();
1097
+ enqueueQueued({
1098
+ kind: 'human',
1099
+ text: commitReviewPrompt(value.sha),
1100
+ });
1101
+ setChoicePicker(null);
1102
+ return;
1103
+ }
660
1104
  setChoicePicker(null);
1105
+ if (picker.kind === 'command') {
1106
+ const command = value;
1107
+ if (command.problem) {
1108
+ pushMessage('system', `Cannot run /${command.name}: ${command.problem}`);
1109
+ return;
1110
+ }
1111
+ commandPickerSubmitRef.current(`/${command.name}`);
1112
+ return;
1113
+ }
1114
+ if (picker.kind === 'archive-conversation') {
1115
+ if (value === 'archive')
1116
+ archiveCurrentConversation();
1117
+ return;
1118
+ }
1119
+ if (picker.kind === 'export-destination') {
1120
+ if (value === 'clipboard') {
1121
+ runConversationExport({ kind: 'clipboard' });
1122
+ return;
1123
+ }
1124
+ const sessionId = scopeRef.current?.sessionId;
1125
+ if (!sessionId) {
1126
+ pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
1127
+ return;
1128
+ }
1129
+ textPromptTokenRef.current += 1;
1130
+ setTextPrompt({
1131
+ token: textPromptTokenRef.current,
1132
+ kind: 'export-file',
1133
+ title: 'Save conversation',
1134
+ placeholder: 'Type a Markdown filename and press Enter',
1135
+ emptyNotice: 'A filename is required. Press Esc to cancel.',
1136
+ initialValue: `namzu-conversation-${sessionId}.md`,
1137
+ sessionId,
1138
+ });
1139
+ return;
1140
+ }
661
1141
  if (picker.kind === 'permission-mode') {
662
1142
  applyPermissionMode(value);
663
1143
  return;
664
1144
  }
1145
+ if (picker.kind === 'feedback-rating') {
1146
+ recordFeedback(picker.runId, picker.messageId, value);
1147
+ return;
1148
+ }
1149
+ if (picker.kind === 'skill') {
1150
+ activateSkill(value);
1151
+ return;
1152
+ }
1153
+ if (picker.kind === 'credential-logout') {
1154
+ removeStoredCredential(value);
1155
+ return;
1156
+ }
665
1157
  applyReasoningEffort(value);
666
- }, [applyPermissionMode, applyReasoningEffort, setChoicePicker]);
1158
+ }, [
1159
+ activateSkill,
1160
+ advanceQueueContinuation,
1161
+ appLifetime.signal,
1162
+ applyPermissionMode,
1163
+ applyReasoningEffort,
1164
+ archiveCurrentConversation,
1165
+ ctx.cwd,
1166
+ enqueueQueued,
1167
+ pushMessage,
1168
+ recordFeedback,
1169
+ removeStoredCredential,
1170
+ runConversationExport,
1171
+ setChoicePicker,
1172
+ setTextPrompt,
1173
+ ]);
667
1174
  const sendCopyRequest = useCallback((index) => {
668
1175
  const picker = copyPickerRef.current;
669
1176
  const target = picker?.targets[index];
@@ -734,14 +1241,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
734
1241
  const finalizeMessage = useCallback((id, finalContent) => {
735
1242
  setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
736
1243
  }, []);
737
- // Open the SDK session store + start a fresh conversation once. Best-effort:
738
- // on failure persistence is simply unavailable and the chat still works.
1244
+ // Open the SDK session store and select the durable conversation once.
1245
+ // Ordinary startup remains best-effort; an explicit `namzu resume <id>` is
1246
+ // exact and therefore refuses instead of silently widening to a fresh chat.
739
1247
  const ensureSessions = useCallback(async () => {
740
1248
  if (scopeRef.current)
741
1249
  return scopeRef.current;
1250
+ const requestedConversationId = initialConversationIdRef.current;
742
1251
  try {
743
1252
  const sessions = await openSessions(ctxRef.current.cwd);
744
- const sessionId = await startConversation(sessions);
1253
+ let sessionId;
1254
+ if (requestedConversationId) {
1255
+ const restored = await loadResumableConversation(sessions, requestedConversationId);
1256
+ sessionId = asSessionId(requestedConversationId);
1257
+ modelHistoryRef.current = restored;
1258
+ setMessages(projectConversation(restored, nextId));
1259
+ setHistory(promptHistoryFromConversation(restored));
1260
+ const persistedOutput = latestAssistantOutput(restored);
1261
+ lastCompletedOutputRef.current = persistedOutput
1262
+ ? { text: persistedOutput, provenance: 'persisted' }
1263
+ : null;
1264
+ initialConversationIdRef.current = undefined;
1265
+ }
1266
+ else {
1267
+ sessionId = await startConversation(sessions);
1268
+ }
745
1269
  sessionsRef.current = sessions;
746
1270
  scopeRef.current = {
747
1271
  sessionId,
@@ -751,10 +1275,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
751
1275
  };
752
1276
  return scopeRef.current;
753
1277
  }
754
- catch {
1278
+ catch (error) {
1279
+ if (requestedConversationId)
1280
+ throw error;
755
1281
  return undefined;
756
1282
  }
757
- }, []);
1283
+ }, [nextId]);
758
1284
  const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
759
1285
  if (signal?.aborted)
760
1286
  return;
@@ -765,6 +1291,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
765
1291
  const s = await createAgentSession(prefs, detectedNow, {
766
1292
  scope,
767
1293
  cwd: activeCtx.cwd,
1294
+ enableComputerUse: true,
768
1295
  rules: activeCtx.rules,
769
1296
  ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
770
1297
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
@@ -805,6 +1332,13 @@ export function App({ ctx: initialCtx, onExitSummary }) {
805
1332
  // the file is shadowed instead of leaving it silently unused.
806
1333
  reserved: hostCommandNames(),
807
1334
  }));
1335
+ const mentionLoadOwner = {};
1336
+ mentionLoadOwnerRef.current = mentionLoadOwner;
1337
+ void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
1338
+ if (!appLifetime.signal.aborted &&
1339
+ mentionLoadOwnerRef.current === mentionLoadOwner)
1340
+ setMentionCandidates(files);
1341
+ });
808
1342
  setCurrentProvider(primaryProvider(prefs).id);
809
1343
  // A picker-owned, usable provider switch is an explicit recovery from a
810
1344
  // paused failed turn. Failed and superseded candidates return above and
@@ -852,7 +1386,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
852
1386
  if (s.errorHint)
853
1387
  pushMessage('system', s.errorHint);
854
1388
  }
855
- }, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
1389
+ }, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
856
1390
  /**
857
1391
  * Sign in to a subscription without leaving namzu.
858
1392
  *
@@ -1015,6 +1549,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1015
1549
  try {
1016
1550
  if (signal?.aborted)
1017
1551
  return;
1552
+ // An exact shell resume owns conversation admission before provider
1553
+ // discovery or construction. A malformed/missing id must not consume
1554
+ // model work and then quietly continue in a fresh conversation.
1555
+ if (initialConversationIdRef.current) {
1556
+ try {
1557
+ await ensureSessions();
1558
+ }
1559
+ catch (error) {
1560
+ setPhase('unhealthy');
1561
+ pushMessage('system', `Could not resume ${initialConversationIdRef.current}: ${error instanceof Error ? error.message : String(error)}`);
1562
+ return;
1563
+ }
1564
+ }
1018
1565
  const probe = await probeAgentSession();
1019
1566
  if (signal?.aborted)
1020
1567
  return;
@@ -1102,7 +1649,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1102
1649
  setPhase('unhealthy');
1103
1650
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
1104
1651
  }
1105
- }, [appLifetime.signal, hydrateSession, pushMessage]);
1652
+ }, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
1106
1653
  // `startOrFinishLogin` is declared above `runProbe` and calls it, so it
1107
1654
  // reads the current one through a ref rather than closing over a stale
1108
1655
  // binding or forcing the two into a declaration order that reads backwards.
@@ -1146,51 +1693,22 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1146
1693
  }
1147
1694
  }, [activateTrustedProject, pushMessage, runProbe]);
1148
1695
  const finalized = messages.filter((m) => !m.pending);
1149
- // How much of the transcript is still redrawable. Computed here rather than
1150
- // inside <Transcript> because the same split decides what the bottom spacer
1151
- // measures: rows in scrollback are content it has to make room for, rows in
1152
- // the window are part of the live region it is padding above. Two
1153
- // computations of one split would drift, and the direction they would drift
1154
- // in is a composer pushed off the screen.
1696
+ // How much of the transcript is still redrawable. The rest belongs to native
1697
+ // terminal scrollback; the live tail stays deliberately small so an activity
1698
+ // tick never repaints the whole conversation.
1155
1699
  //
1156
1700
  // A ref, and mutated during render, because the split has to be MONOTONIC:
1157
1701
  // a row that has been printed to scrollback can never come back, and `max`
1158
1702
  // is idempotent, so a repeated render reaches the same answer.
1159
1703
  const window = liveWindow({
1160
1704
  messages: finalized,
1161
- rows: stdout.rows,
1162
- columns: stdout.columns,
1705
+ rows: terminal.rows,
1706
+ columns: terminal.columns,
1163
1707
  furnitureRows: LIVE_FURNITURE_ROWS,
1164
1708
  settled: settledRef.current,
1165
1709
  raw: rawOutput,
1166
1710
  });
1167
1711
  settledRef.current = window.settled;
1168
- // Blank rows above the composer, while the transcript is short enough that
1169
- // the answer is knowable. `liveRows` is the furniture beneath the transcript
1170
- // PLUS the live window above it — the window is part of the live region, and
1171
- // leaving it out would have the spacer padding room that is already taken.
1172
- const spacerCandidate = phase === 'ready'
1173
- ? bottomSpacerRows({
1174
- rows: stdout.rows,
1175
- columns: stdout.columns,
1176
- transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
1177
- liveRows: LIVE_FURNITURE_ROWS + window.rows,
1178
- })
1179
- : 0;
1180
- const spacerLayout = {
1181
- rows: stdout.rows,
1182
- columns: stdout.columns,
1183
- raw: rawOutput,
1184
- messageCount: finalized.length,
1185
- tail: finalized
1186
- .slice(-MAX_LIVE_ROWS)
1187
- .map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
1188
- };
1189
- const spacerRows = phase === 'ready' && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
1190
- ? spacerLayoutRef.current.spacerRows
1191
- : spacerCandidate;
1192
- spacerLayoutRef.current =
1193
- phase === 'ready' ? { ...spacerLayout, spacerRows } : null;
1194
1712
  // One merged vocabulary for the session: this host's own commands plus
1195
1713
  // whatever the kernel's registry reports. Built here so `/help`, the
1196
1714
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1198,6 +1716,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1198
1716
  // disagree the moment a capability added a command.
1199
1717
  const hostCommands = mergeHostCommands(kernelCommandDescriptors());
1200
1718
  const slashCtx = {
1719
+ cwd: ctx.cwd,
1201
1720
  builtins: hostCommands,
1202
1721
  lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
1203
1722
  // Called when `/tools` renders, not read here — the same shape, and the
@@ -1206,7 +1725,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1206
1725
  // From the session, not re-resolved: resolving builds a provider, and a
1207
1726
  // second one would describe a different sandbox than the run is using.
1208
1727
  sandbox: session?.sandbox ?? null,
1209
- mcp: session ? { connected: session.mcpConnected, failed: session.mcpFailed } : null,
1728
+ mcp: () => session
1729
+ ? (session.mcpStatus?.() ?? {
1730
+ connected: session.mcpConnected,
1731
+ failed: session.mcpFailed,
1732
+ })
1733
+ : null,
1210
1734
  providerSummary: session?.providerSummary ?? null,
1211
1735
  modelSummary: session?.modelSummary ?? null,
1212
1736
  reasoningEffort: {
@@ -1269,10 +1793,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1269
1793
  const resolve = permissionResolveRef.current;
1270
1794
  permissionResolveRef.current = null;
1271
1795
  permissionOpenedAtRef.current = null;
1796
+ setPermissionReviewOffset(0);
1272
1797
  setPermission(null);
1273
1798
  if (resolve)
1274
1799
  resolve(decision);
1275
- }, []);
1800
+ }, [setPermissionReviewOffset]);
1276
1801
  /**
1277
1802
  * Stop the running turn, and hand the screen back in a usable state.
1278
1803
  *
@@ -1297,7 +1822,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1297
1822
  const ac = abortRef.current;
1298
1823
  if (!ac)
1299
1824
  return false;
1300
- ac.abort();
1825
+ ac.abort(new RunCancelled('user'));
1301
1826
  const activeSessionId = scopeRef.current?.sessionId;
1302
1827
  if (activeSessionId)
1303
1828
  goalActivation.disarm(activeSessionId);
@@ -1390,6 +1915,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1390
1915
  resetTranscript();
1391
1916
  setMessages(restored);
1392
1917
  modelHistoryRef.current = msgs;
1918
+ setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
1393
1919
  const persistedOutput = latestAssistantOutput(msgs);
1394
1920
  lastCompletedOutputRef.current = persistedOutput
1395
1921
  ? { text: persistedOutput, provenance: 'persisted' }
@@ -1486,11 +2012,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1486
2012
  }
1487
2013
  }, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
1488
2014
  /**
1489
- * `/title`: read or set the name this conversation appears under.
2015
+ * `/rename` (and the older `/title` alias): edit or set the name this
2016
+ * conversation appears under.
1490
2017
  *
1491
- * A bare `/title` asks rather than clears. Erasing a name by pressing
1492
- * enter on a half-typed command is the kind of loss nobody notices until
1493
- * `/resume` is a list of opening messages again.
2018
+ * A bare command opens a prefilled host prompt rather than printing usage or
2019
+ * clearing. The result never goes through the model composer, queue, or prompt
2020
+ * history; it is a session-store operation with the session id captured here.
1494
2021
  */
1495
2022
  const doTitle = useCallback(async (title, clear) => {
1496
2023
  const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
@@ -1502,9 +2029,16 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1502
2029
  try {
1503
2030
  if (!clear && title === '') {
1504
2031
  const current = titleOf(sessions, scope.sessionId);
1505
- pushMessage('system', current !== undefined
1506
- ? `This conversation is named "${current}". /title <name> renames it; /title clear removes the name.`
1507
- : 'This conversation has no name, so /resume lists it by its opening message — which stops describing it as the conversation moves on. /title <name> fixes that.');
2032
+ textPromptTokenRef.current += 1;
2033
+ setTextPrompt({
2034
+ token: textPromptTokenRef.current,
2035
+ kind: 'conversation-title',
2036
+ title: current === undefined ? 'Name conversation' : 'Rename conversation',
2037
+ placeholder: 'Type a name and press Enter',
2038
+ emptyNotice: 'A conversation name cannot be empty. Press Esc to cancel.',
2039
+ initialValue: current ?? '',
2040
+ sessionId: scope.sessionId,
2041
+ });
1508
2042
  return;
1509
2043
  }
1510
2044
  setTitle(sessions, scope.sessionId, clear ? '' : title);
@@ -1515,7 +2049,46 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1515
2049
  catch (err) {
1516
2050
  pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
1517
2051
  }
1518
- }, [ensureSessions, pushMessage]);
2052
+ }, [ensureSessions, pushMessage, setTextPrompt]);
2053
+ const submitTextPrompt = useCallback((value) => {
2054
+ const prompt = textPromptRef.current;
2055
+ if (!prompt)
2056
+ return;
2057
+ setTextPrompt(null);
2058
+ const scope = scopeRef.current;
2059
+ if (!scope || scope.sessionId !== prompt.sessionId) {
2060
+ pushMessage('system', prompt.kind === 'conversation-title'
2061
+ ? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
2062
+ : 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
2063
+ return;
2064
+ }
2065
+ if (prompt.kind === 'export-file') {
2066
+ runConversationExport({ kind: 'file', path: value });
2067
+ return;
2068
+ }
2069
+ const sessions = sessionsRef.current;
2070
+ if (!sessions) {
2071
+ pushMessage('system', 'Conversation history is unavailable in this folder.');
2072
+ return;
2073
+ }
2074
+ try {
2075
+ setTitle(sessions, prompt.sessionId, value);
2076
+ pushMessage('system', `Named "${value.trim()}".`);
2077
+ }
2078
+ catch (error) {
2079
+ pushMessage('system', `Could not save the name: ${error instanceof Error ? error.message : String(error)}`);
2080
+ }
2081
+ }, [pushMessage, runConversationExport, setTextPrompt]);
2082
+ const cancelTextPrompt = useCallback(() => {
2083
+ const prompt = textPromptRef.current;
2084
+ // TextPrompt and App both receive the same Ink input event. Keep the
2085
+ // synchronous owner until every listener has seen this Esc/Ctrl+C, or App
2086
+ // can read the same key as an interrupt/exit after the child clears the ref.
2087
+ queueMicrotask(() => {
2088
+ if (textPromptRef.current === prompt)
2089
+ setTextPrompt(null);
2090
+ });
2091
+ }, [setTextPrompt]);
1519
2092
  /**
1520
2093
  * `/fork`: continue in a copy, leaving this conversation where it is.
1521
2094
  *
@@ -1671,13 +2244,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1671
2244
  ]);
1672
2245
  // Bridge passed into session.send(): the agent calls this before a
1673
2246
  // non-read-only tool batch; it parks until the user presses y/n/a.
1674
- const onPermission = useCallback((req) => new Promise((resolve) => {
1675
- permissionResolveRef.current = resolve;
1676
- permissionOpenedAtRef.current = Date.now();
1677
- setPermission(req);
1678
- setState('awaiting-permission');
1679
- sendTerminalNotification({ kind: 'approval-required' });
1680
- }), [sendTerminalNotification]);
2247
+ const onPermission = useCallback((req) => {
2248
+ const review = buildPermissionReview(req.toolCalls);
2249
+ if (!review.ok) {
2250
+ return Promise.resolve({
2251
+ kind: 'reject',
2252
+ feedback: permissionReviewRefusal(review.reason),
2253
+ });
2254
+ }
2255
+ return new Promise((resolve) => {
2256
+ permissionResolveRef.current = resolve;
2257
+ permissionOpenedAtRef.current = Date.now();
2258
+ setPermissionReviewOffset(0);
2259
+ setPermission({ ...req, review: review.text });
2260
+ setState('awaiting-permission');
2261
+ sendTerminalNotification({ kind: 'approval-required' });
2262
+ });
2263
+ }, [sendTerminalNotification, setPermissionReviewOffset]);
1681
2264
  // Render one agent event onto the transcript. Shared by the local turn loop
1682
2265
  // and the daemon-attach poller, so both paths produce identical output.
1683
2266
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -1726,7 +2309,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1726
2309
  const tool = {
1727
2310
  id: event.toolUseId,
1728
2311
  toolName: event.toolName,
1729
- label: formatToolCall(event.toolName, event.summary),
2312
+ label: formatToolCall(event.toolName, event.summary, event.standalone),
1730
2313
  startedAt: Date.now(),
1731
2314
  detail: event.detail,
1732
2315
  };
@@ -1769,7 +2352,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1769
2352
  setActiveTools(activeToolsRef.current);
1770
2353
  }
1771
2354
  pushMessage('tool', done?.label ?? formatToolCall(event.toolName, event.summary), false, event.isError ? '✗' : '✓', done?.detail, event.isError ? theme.status.error : theme.status.ok, done ? formatElapsed(Date.now() - done.startedAt) : undefined);
1772
- if (event.isError || event.summary.length > 0 || (event.detail?.length ?? 0) > 0) {
2355
+ if (event.isError ||
2356
+ (!event.hidden && (event.summary.length > 0 || (event.detail?.length ?? 0) > 0))) {
1773
2357
  pushMessage('tool', event.isError ? `failed: ${event.summary}` : event.summary, false, '⎿', event.detail);
1774
2358
  }
1775
2359
  setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
@@ -1812,7 +2396,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1812
2396
  pushMessage('system', event.text, false, '⇄');
1813
2397
  break;
1814
2398
  case 'capability-warning':
1815
- pushMessage('system', `Capability warning (${event.capability}): ${event.text}`, false, '⚠');
2399
+ pushMessage('system', `Capability warning (${event.capability}${event.contentSource === 'tool-result' ? ' tool result' : ''}): ${event.text}`, false, '⚠');
1816
2400
  break;
1817
2401
  case 'history-repair':
1818
2402
  pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '⚠');
@@ -1831,12 +2415,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1831
2415
  };
1832
2416
  closeAssistant();
1833
2417
  break;
2418
+ case 'paused':
2419
+ // Paused is a recoverable SDK verdict, not success and not failure.
2420
+ // Conversation evidence has no paused member, so it records the
2421
+ // truthful coarse outcome (`stopped`) while queue copy retains the
2422
+ // distinction the operator acts on.
2423
+ closeAssistant();
2424
+ st.outcome = 'stopped';
2425
+ st.queuePauseOutcome = 'paused';
2426
+ st.notification = { kind: 'turn-settled', outcome: 'stopped' };
2427
+ pushMessage('system', describeRunInterruption(event), false, '⏸');
2428
+ break;
1834
2429
  case 'error':
1835
2430
  closeAssistant();
1836
2431
  st.outcome = event.message === 'aborted' ? 'cancelled' : 'failed';
1837
2432
  if (event.message !== 'aborted') {
1838
2433
  st.notification = { kind: 'turn-settled', outcome: 'failed' };
1839
- pushMessage('system', `Error: ${event.message}`);
2434
+ pushMessage('system', describeRunInterruption(event), false, '⚠');
1840
2435
  }
1841
2436
  else
1842
2437
  st.notification = null;
@@ -1933,7 +2528,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1933
2528
  abnormalTerminal = {
1934
2529
  token: turnToken,
1935
2530
  continuationEpoch: queueContinuationEpochRef.current,
1936
- outcome: st.outcome,
2531
+ outcome: st.queuePauseOutcome ?? st.outcome,
1937
2532
  };
1938
2533
  };
1939
2534
  abortRef.current = ac;
@@ -2268,7 +2863,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2268
2863
  ? 'forked'
2269
2864
  : conversationMutationRef.current === 'edit'
2270
2865
  ? 'branched for prompt editing'
2271
- : 'moved to a fresh conversation';
2866
+ : conversationMutationRef.current === 'archive'
2867
+ ? 'archived'
2868
+ : 'moved to a fresh conversation';
2272
2869
  pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
2273
2870
  return;
2274
2871
  }
@@ -2284,13 +2881,74 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2284
2881
  case 'message':
2285
2882
  pushMessage(slash.role, slash.content);
2286
2883
  return;
2884
+ case 'command-picker': {
2885
+ if (slash.commands.length === 0) {
2886
+ pushMessage('system', 'No slash commands are available in this session.');
2887
+ return;
2888
+ }
2889
+ setSelectedChoice(0);
2890
+ setChoicePicker({
2891
+ kind: 'command',
2892
+ title: 'Choose a command',
2893
+ notice: 'Enter runs the selected command; Esc returns to the composer.',
2894
+ values: slash.commands,
2895
+ options: slash.commands.map((command) => ({
2896
+ label: `/${command.name}`,
2897
+ description: command.problem
2898
+ ? `Unavailable: ${command.problem}`
2899
+ : command.description,
2900
+ })),
2901
+ windowSize: suggestionWindowSize(terminal.rows),
2902
+ });
2903
+ return;
2904
+ }
2287
2905
  case 'clear-screen':
2288
2906
  setMessages([]);
2289
2907
  resetTranscript();
2290
2908
  return;
2909
+ case 'composer-draft':
2910
+ composerDraftTokenRef.current += 1;
2911
+ setComposerDraft({
2912
+ token: composerDraftTokenRef.current,
2913
+ text: slash.text,
2914
+ });
2915
+ return;
2291
2916
  case 'new-conversation':
2292
2917
  void startFreshConversation(slash.clearScreen);
2293
2918
  return;
2919
+ case 'archive-picker': {
2920
+ if (!sessionsRef.current || !scopeRef.current?.sessionId) {
2921
+ pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
2922
+ return;
2923
+ }
2924
+ if (state !== 'idle' ||
2925
+ abortRef.current !== null ||
2926
+ hasUnsettledTurn() ||
2927
+ queuedRef.current.length > 0 ||
2928
+ compactingRef.current ||
2929
+ exportingRef.current) {
2930
+ pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
2931
+ return;
2932
+ }
2933
+ setSelectedChoice(0);
2934
+ setChoicePicker({
2935
+ kind: 'archive-conversation',
2936
+ title: 'Archive this conversation?',
2937
+ notice: 'History remains on disk for inspection, but the conversation becomes read-only and disappears from /resume.',
2938
+ values: ['cancel', 'archive'],
2939
+ options: [
2940
+ {
2941
+ label: 'No, keep conversation',
2942
+ description: 'Return without changing durable history',
2943
+ },
2944
+ {
2945
+ label: 'Yes, archive and exit',
2946
+ description: 'Make this conversation read-only and leave Namzu',
2947
+ },
2948
+ ],
2949
+ });
2950
+ return;
2951
+ }
2294
2952
  case 'exit':
2295
2953
  exitWithSummary();
2296
2954
  return;
@@ -2377,16 +3035,35 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2377
3035
  void startOrFinishLogin(slash.pasted);
2378
3036
  return;
2379
3037
  case 'logout': {
2380
- const path = credentialsPath();
2381
- const had = readStoredSubscriptionCredential() !== null || readStoredCodexCredential() !== null;
2382
- try {
2383
- clearAllStoredCredentials();
3038
+ if (slash.target) {
3039
+ removeStoredCredential(slash.target);
3040
+ return;
2384
3041
  }
2385
- catch (err) {
2386
- pushMessage('system', `Could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
3042
+ const choices = [
3043
+ ...(readStoredSubscriptionCredential() ? ['anthropic'] : []),
3044
+ ...(readStoredCodexCredential() ? ['codex'] : []),
3045
+ ];
3046
+ if (choices.length === 0) {
3047
+ removeStoredCredential('all');
3048
+ return;
3049
+ }
3050
+ if (choices.length === 1) {
3051
+ removeStoredCredential(choices[0]);
2387
3052
  return;
2388
3053
  }
2389
- pushMessage('system', describeLogout(path, had));
3054
+ setSelectedChoice(0);
3055
+ setChoicePicker({
3056
+ kind: 'credential-logout',
3057
+ title: 'Choose a stored subscription to remove',
3058
+ notice: 'Only credentials created by Namzu are listed. Device sessions owned by other tools are left alone.',
3059
+ values: choices,
3060
+ options: choices.map((choice) => ({
3061
+ label: choice === 'anthropic' ? 'Claude' : 'Codex',
3062
+ description: choice === 'anthropic'
3063
+ ? 'Remove only Namzu’s Claude subscription.'
3064
+ : 'Remove only Namzu’s Codex subscription.',
3065
+ })),
3066
+ });
2390
3067
  return;
2391
3068
  }
2392
3069
  case 'remember':
@@ -2424,23 +3101,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2424
3101
  pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
2425
3102
  return;
2426
3103
  }
2427
- case 'load-skill': {
2428
- const info = discoverSkills({ cwd: ctx.cwd }).find((s) => s.name === slash.name);
2429
- if (!info) {
2430
- pushMessage('system', `No skill named "${slash.name}". See /skills.`);
3104
+ case 'skill-picker': {
3105
+ const skills = discoverSkills({ cwd: ctx.cwd });
3106
+ if (skills.length === 0) {
3107
+ pushMessage('system', 'No skills found. Add one at ~/.namzu/skills/<name>/SKILL.md or ./skills/<name>/SKILL.md.');
2431
3108
  return;
2432
3109
  }
2433
- try {
2434
- const body = loadSkillBody(info);
2435
- setActiveSkills((prev) => [
2436
- ...prev.filter((s) => s.name !== info.name),
2437
- { name: info.name, body },
2438
- ]);
2439
- pushMessage('system', `Activated skill: ${info.name}`);
2440
- }
2441
- catch (err) {
2442
- pushMessage('system', `Could not load skill "${slash.name}": ${err instanceof Error ? err.message : String(err)}`);
2443
- }
3110
+ const activeNames = new Set(activeSkills.map((skill) => skill.name));
3111
+ setSelectedChoice(0);
3112
+ setChoicePicker({
3113
+ kind: 'skill',
3114
+ title: 'Activate a skill',
3115
+ values: skills.map((skill) => skill.name),
3116
+ options: skills.map((skill) => ({
3117
+ label: skill.name,
3118
+ description: skill.problem
3119
+ ? `Unavailable: ${skill.problem}`
3120
+ : `${skill.description} · ${skill.source}`,
3121
+ current: activeNames.has(skill.name),
3122
+ })),
3123
+ });
3124
+ return;
3125
+ }
3126
+ case 'load-skill': {
3127
+ activateSkill(slash.name);
2444
3128
  return;
2445
3129
  }
2446
3130
  case 'resume':
@@ -2586,67 +3270,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2586
3270
  })();
2587
3271
  return;
2588
3272
  }
2589
- case 'feedback': {
3273
+ case 'feedback-picker': {
2590
3274
  const target = lastAssistantMessage.current;
2591
- // Written under the same `<cwd>/.namzu` root the runs live
2592
- // in, so a rating and the transcript it judges are one
2593
- // directory apart and travel together.
2594
- if (!target) {
3275
+ if (!target || target.messageId !== slash.messageId) {
2595
3276
  pushMessage('system', 'Nothing to rate yet.');
2596
3277
  return;
2597
3278
  }
2598
- const store = new DiskMessageFeedbackStore({
2599
- rootDir: join(ctx.cwd, '.namzu', 'feedback'),
2600
- runsDir: join(ctx.cwd, '.namzu', 'runs'),
3279
+ const values = ['good', 'bad'];
3280
+ setSelectedChoice(0);
3281
+ setChoicePicker({
3282
+ kind: 'feedback-rating',
3283
+ title: 'Rate the latest answer',
3284
+ runId: target.runId,
3285
+ messageId: target.messageId,
3286
+ values,
3287
+ options: [
3288
+ {
3289
+ label: 'good',
3290
+ description: 'The answer was useful and correct.',
3291
+ },
3292
+ { label: 'bad', description: 'The answer needs improvement.' },
3293
+ ],
2601
3294
  });
2602
- const runId = target.runId;
2603
- const messageId = slash.messageId;
2604
- // Fire-and-forget: this switch is synchronous, and the
2605
- // transcript reports the outcome either way rather than
2606
- // blocking a keystroke on a disk write.
2607
- void (async () => {
2608
- try {
2609
- // A first rating expects nothing; a second replaces the
2610
- // first, which is what a rater changing their mind does.
2611
- // Read-then-write rather than blind overwrite, so two
2612
- // raters on one message still collide loudly.
2613
- const current = (await store.listMessageFeedback({ runId })).find((r) => r.messageId === messageId);
2614
- const record = await store.putMessageFeedback({
2615
- runId,
2616
- messageId,
2617
- rating: slash.rating,
2618
- ...(slash.note ? { note: slash.note } : {}),
2619
- expectedVersion: current?.ownerVersion ?? 0,
2620
- });
2621
- pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
2622
- }
2623
- catch (err) {
2624
- pushMessage('system', `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`);
2625
- }
2626
- })();
3295
+ return;
3296
+ }
3297
+ case 'feedback': {
3298
+ const target = lastAssistantMessage.current;
3299
+ if (!target || target.messageId !== slash.messageId) {
3300
+ pushMessage('system', 'Nothing to rate yet.');
3301
+ return;
3302
+ }
3303
+ recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
2627
3304
  return;
2628
3305
  }
2629
3306
  case 'review': {
2630
- void (async () => {
2631
- const diff = await workspaceDiff(ctx.cwd);
2632
- if (diff === null) {
2633
- pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
2634
- return;
2635
- }
2636
- if (diff.stat.length === 0 && diff.untracked.length === 0) {
2637
- // Refused rather than sent. A review request over an
2638
- // unchanged tree burns a turn and comes back with a
2639
- // review of nothing, which reads exactly like a review
2640
- // of something.
2641
- pushMessage('system', 'Nothing to review — the working tree is clean.');
2642
- return;
2643
- }
2644
- // The same FIFO a typed message enters. A review composed
2645
- // while a turn is in flight must not jump the queue, and
2646
- // must not be dropped either.
2647
- const text = reviewPrompt(diff.stat, diff.untracked);
2648
- enqueueQueued({ kind: 'human', text });
2649
- })();
3307
+ if (slash.instructions) {
3308
+ // Custom instructions are already operator-authored model input.
3309
+ // They take the ordinary FIFO below rather than a second send path.
3310
+ outgoing = slash.instructions;
3311
+ break;
3312
+ }
3313
+ const values = [
3314
+ 'base-branch',
3315
+ 'uncommitted',
3316
+ 'commit',
3317
+ 'custom',
3318
+ ];
3319
+ setSelectedChoice(0);
3320
+ setChoicePicker({
3321
+ kind: 'review-preset',
3322
+ title: 'Select a review preset',
3323
+ values,
3324
+ options: [
3325
+ {
3326
+ label: 'Base branch',
3327
+ description: 'Review the current work as a branch comparison.',
3328
+ },
3329
+ {
3330
+ label: 'Uncommitted',
3331
+ description: 'Review staged, unstaged, and untracked changes.',
3332
+ },
3333
+ {
3334
+ label: 'Commit',
3335
+ description: 'Review one recent commit.',
3336
+ },
3337
+ {
3338
+ label: 'Custom',
3339
+ description: 'Write exact review instructions in the composer.',
3340
+ },
3341
+ ],
3342
+ });
2650
3343
  return;
2651
3344
  }
2652
3345
  case 'diff': {
@@ -2771,45 +3464,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2771
3464
  : 'Raw output mode off — rich transcript rendering restored.');
2772
3465
  return;
2773
3466
  }
2774
- case 'export': {
2775
- const sessions = sessionsRef.current;
2776
- const destination = scopeRef.current?.sessionId;
2777
- if (!sessions || !destination) {
2778
- pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
3467
+ case 'export-picker': {
3468
+ if (!stableExportSource())
2779
3469
  return;
2780
- }
2781
- if (abortRef.current ||
2782
- state !== 'idle' ||
2783
- hasUnsettledTurn() ||
2784
- compactingRef.current ||
2785
- queuedRef.current.length > 0) {
2786
- pushMessage('system', 'A turn, compaction, or queued prompt is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
2787
- return;
2788
- }
2789
- exportingRef.current = true;
2790
- setState('thinking');
2791
- const generation = conversationGenRef.current;
2792
- const path = slash.path ?? `namzu-conversation-${destination}.md`;
2793
- void (async () => {
2794
- try {
2795
- await persistenceTailRef.current;
2796
- if (conversationGenRef.current !== generation ||
2797
- scopeRef.current?.sessionId !== destination) {
2798
- throw new Error('The active conversation changed before the export reached its durable boundary.');
2799
- }
2800
- const projected = await conversationMarkdown(sessions, destination);
2801
- const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
2802
- pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
2803
- }
2804
- catch (err) {
2805
- pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
2806
- }
2807
- finally {
2808
- exportingRef.current = false;
2809
- if (conversationGenRef.current === generation)
2810
- setState('idle');
2811
- }
2812
- })();
3470
+ setSelectedChoice(0);
3471
+ setChoicePicker({
3472
+ kind: 'export-destination',
3473
+ title: 'Export conversation',
3474
+ notice: 'Save the complete verified conversation as Markdown.',
3475
+ values: ['clipboard', 'file'],
3476
+ options: [
3477
+ {
3478
+ label: 'Copy to clipboard',
3479
+ description: 'Send the verified Markdown transcript to the terminal clipboard',
3480
+ },
3481
+ {
3482
+ label: 'Save to file',
3483
+ description: 'Choose a Markdown filename; existing files are never overwritten',
3484
+ },
3485
+ ],
3486
+ });
3487
+ return;
3488
+ }
3489
+ case 'export': {
3490
+ runConversationExport({ kind: 'file', path: slash.path });
2813
3491
  return;
2814
3492
  }
2815
3493
  default: {
@@ -2865,14 +3543,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2865
3543
  nextId,
2866
3544
  pushMessage,
2867
3545
  rawOutput,
3546
+ removeStoredCredential,
2868
3547
  resetTranscript,
3548
+ runConversationExport,
2869
3549
  setChoicePicker,
2870
3550
  setCopyPicker,
2871
3551
  setReasoningEffort,
2872
3552
  slashCtx,
3553
+ stableExportSource,
2873
3554
  startFreshConversation,
2874
3555
  state,
3556
+ terminal.rows,
2875
3557
  ]);
3558
+ // `applyChoiceSelection` is declared before `handleSubmit` because the
3559
+ // picker is also used by review/export flows. Keep only this dispatch hop in
3560
+ // a ref so selecting a help row re-enters the one ordinary slash-command
3561
+ // path instead of growing a second command executor.
3562
+ commandPickerSubmitRef.current = handleSubmit;
2876
3563
  // Keep the footer on the same durable goal record the driver mutates. A ref
2877
3564
  // alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
2878
3565
  // generation/session checks stop a late disk read labelling a resumed chat.
@@ -2925,6 +3612,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2925
3612
  conversationMutationRef.current ||
2926
3613
  exportingRef.current ||
2927
3614
  compactingRef.current ||
3615
+ textPromptRef.current ||
2928
3616
  choicePickerRef.current ||
2929
3617
  copyPickerRef.current ||
2930
3618
  goalCommandInFlightRef.current) {
@@ -2951,6 +3639,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2951
3639
  conversationMutationRef.current ||
2952
3640
  exportingRef.current ||
2953
3641
  compactingRef.current ||
3642
+ textPromptRef.current ||
2954
3643
  choicePickerRef.current ||
2955
3644
  copyPickerRef.current ||
2956
3645
  goalCommandInFlightRef.current ||
@@ -3010,6 +3699,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3010
3699
  conversationMutation,
3011
3700
  session,
3012
3701
  state,
3702
+ textPrompt,
3013
3703
  choicePicker,
3014
3704
  copyPicker,
3015
3705
  wakeGoalDriver,
@@ -3026,6 +3716,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3026
3716
  conversationMutationRef.current ||
3027
3717
  exportingRef.current ||
3028
3718
  compactingRef.current ||
3719
+ textPromptRef.current ||
3029
3720
  choicePickerRef.current ||
3030
3721
  copyPickerRef.current ||
3031
3722
  goalCommandInFlightRef.current)
@@ -3042,6 +3733,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3042
3733
  phase,
3043
3734
  queued,
3044
3735
  queuePause,
3736
+ textPrompt,
3045
3737
  choicePicker,
3046
3738
  copyPicker,
3047
3739
  dequeueQueued,
@@ -3195,16 +3887,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3195
3887
  setPhase('ready');
3196
3888
  return;
3197
3889
  }
3890
+ if (key.home || key.end || key.pageUp || key.pageDown) {
3891
+ setSelectedEdit((index) => moveSelection(index, editList.length, key.home
3892
+ ? 'first'
3893
+ : key.end
3894
+ ? 'last'
3895
+ : key.pageUp
3896
+ ? 'previous-page'
3897
+ : 'next-page'));
3898
+ return;
3899
+ }
3198
3900
  if (key.escape || key.leftArrow || key.upArrow) {
3199
- setSelectedEdit((index) => Math.max(0, index - 1));
3901
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
3200
3902
  return;
3201
3903
  }
3202
3904
  if (key.rightArrow || key.downArrow) {
3203
- setSelectedEdit((index) => Math.min(editList.length - 1, index + 1));
3905
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
3204
3906
  return;
3205
3907
  }
3206
3908
  if (key.return) {
3207
- const target = editList[selectedEdit];
3909
+ const target = editList[selectedEditRef.current];
3208
3910
  if (target)
3209
3911
  void confirmPromptEdit(target);
3210
3912
  }
@@ -3218,12 +3920,21 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3218
3920
  // replaced regardless.
3219
3921
  if (resumeCommittedRef.current)
3220
3922
  return;
3221
- if (key.upArrow)
3222
- setSelectedResume((i) => Math.max(0, i - 1));
3923
+ if (key.home || key.end || key.pageUp || key.pageDown) {
3924
+ setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
3925
+ ? 'first'
3926
+ : key.end
3927
+ ? 'last'
3928
+ : key.pageUp
3929
+ ? 'previous-page'
3930
+ : 'next-page'));
3931
+ }
3932
+ else if (key.upArrow)
3933
+ setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
3223
3934
  else if (key.downArrow)
3224
- setSelectedResume((i) => Math.min(resumeList.length - 1, i + 1));
3935
+ setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
3225
3936
  else if (key.return) {
3226
- const conv = resumeList[selectedResume];
3937
+ const conv = resumeList[selectedResumeRef.current];
3227
3938
  if (conv)
3228
3939
  void resumeConversation(conv);
3229
3940
  }
@@ -3260,12 +3971,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3260
3971
  }
3261
3972
  // A pending permission prompt owns the keyboard: y/n/a decide it.
3262
3973
  if (permissionResolveRef.current) {
3974
+ if (permission &&
3975
+ (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
3976
+ const count = permissionReviewRows(permission.review, terminal.columns).length;
3977
+ const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
3978
+ const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
3979
+ const next = key.home
3980
+ ? 0
3981
+ : key.end
3982
+ ? maxOffset
3983
+ : key.pageUp
3984
+ ? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
3985
+ : key.pageDown
3986
+ ? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
3987
+ : key.upArrow
3988
+ ? Math.max(0, current - 1)
3989
+ : Math.min(maxOffset, current + 1);
3990
+ setPermissionReviewOffset(next);
3991
+ return;
3992
+ }
3263
3993
  const ch = input.toLowerCase();
3264
3994
  // Refusing is never deferred or gated — it is the direction a
3265
3995
  // mistake is recoverable in, so it answers on the first press.
3266
3996
  if (key.ctrl && input === 'c') {
3267
3997
  resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
3268
- abortRef.current?.abort();
3998
+ abortRef.current?.abort(new RunCancelled('user'));
3269
3999
  return;
3270
4000
  }
3271
4001
  if (ch === 'n' || key.escape) {
@@ -3286,6 +4016,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3286
4016
  resolvePermission({ kind: 'approve-all' });
3287
4017
  return;
3288
4018
  }
4019
+ // A host-owned text prompt has its own input hook. This synchronous ref
4020
+ // claims the interval before React paints it too, so App cannot read a
4021
+ // key repeat as an interrupt, exit ladder, or finite-menu choice.
4022
+ if (textPromptRef.current)
4023
+ return;
3289
4024
  // Finite slash-command choices own the keyboard below a live permission
3290
4025
  // request and above the ordinary composer. Their values are a captured
3291
4026
  // menu, so a provider/session change cannot silently reinterpret Enter.
@@ -3299,19 +4034,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3299
4034
  return;
3300
4035
  const options = picker.options;
3301
4036
  if (key.escape || (key.ctrl && input === 'c')) {
4037
+ reviewChoiceInFlightRef.current = null;
3302
4038
  setChoicePicker(null);
3303
4039
  return;
3304
4040
  }
4041
+ if (key.home || key.end || key.pageUp || key.pageDown) {
4042
+ const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
4043
+ setSelectedChoice((index) => moveSelection(index, options.length, key.home
4044
+ ? 'first'
4045
+ : key.end
4046
+ ? 'last'
4047
+ : key.pageUp
4048
+ ? 'previous-page'
4049
+ : 'next-page', pageSize));
4050
+ return;
4051
+ }
3305
4052
  if (key.upArrow) {
3306
- setSelectedChoice((index) => Math.max(0, index - 1));
4053
+ setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
3307
4054
  return;
3308
4055
  }
3309
4056
  if (key.downArrow) {
3310
- setSelectedChoice((index) => Math.min(options.length - 1, index + 1));
4057
+ setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
3311
4058
  return;
3312
4059
  }
3313
4060
  if (key.return) {
3314
- applyChoiceSelection(selectedChoice);
4061
+ applyChoiceSelection(selectedChoiceRef.current);
3315
4062
  return;
3316
4063
  }
3317
4064
  if (/^[1-9]$/.test(input)) {
@@ -3330,16 +4077,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3330
4077
  setCopyPicker(null);
3331
4078
  return;
3332
4079
  }
4080
+ if (key.home || key.end || key.pageUp || key.pageDown) {
4081
+ setSelectedCopy((index) => moveSelection(index, targets.length, key.home
4082
+ ? 'first'
4083
+ : key.end
4084
+ ? 'last'
4085
+ : key.pageUp
4086
+ ? 'previous-page'
4087
+ : 'next-page'));
4088
+ return;
4089
+ }
3333
4090
  if (key.upArrow) {
3334
- setSelectedCopy((index) => Math.max(0, index - 1));
4091
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
3335
4092
  return;
3336
4093
  }
3337
4094
  if (key.downArrow) {
3338
- setSelectedCopy((index) => Math.min(targets.length - 1, index + 1));
4095
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
3339
4096
  return;
3340
4097
  }
3341
4098
  if (key.return) {
3342
- sendCopyRequest(selectedCopy);
4099
+ sendCopyRequest(selectedCopyRef.current);
3343
4100
  return;
3344
4101
  }
3345
4102
  if (/^[1-9]$/.test(input)) {
@@ -3349,6 +4106,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3349
4106
  }
3350
4107
  return;
3351
4108
  }
4109
+ // Terminal-native display clear. It deliberately shares `/clear-screen`'s
4110
+ // view-only boundary: model history, durable history and the active
4111
+ // conversation remain untouched. A live turn is refused instead of
4112
+ // erasing the only visible account of work that is still changing.
4113
+ if (key.ctrl && input === 'l') {
4114
+ if (state !== 'idle' || abortRef.current || hasUnsettledTurn()) {
4115
+ pushMessage('system', 'Ctrl+L is unavailable while a turn is still in progress.');
4116
+ return;
4117
+ }
4118
+ setMessages([]);
4119
+ resetTranscript();
4120
+ return;
4121
+ }
3352
4122
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
3353
4123
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
3354
4124
  if (key.escape && abortRef.current) {
@@ -3421,33 +4191,43 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3421
4191
  // Active on every phase. It was gated off during the picker, which is what
3422
4192
  // left that screen without a usable exit; the picker branch above returns
3423
4193
  // immediately, so the Picker still owns everything except Ctrl+C.
3424
- { isActive: true });
4194
+ { isActive: externalEditorRequest === null });
3425
4195
  // Background is left natural — we inherit the terminal's own background
3426
4196
  // and only theme the foreground. Forcing
3427
4197
  // a filled bg left mismatched patches around bordered areas, so we don't.
3428
- return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [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: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, spacerRows: spacerRows, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls })) : choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice })) : copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
4198
+ return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [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: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: terminal.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
3429
4199
  phase === 'ready' &&
3430
4200
  conversationMutation === null &&
4201
+ textPrompt === null &&
3431
4202
  choicePicker === null &&
3432
4203
  copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
3433
4204
  permission === null &&
4205
+ textPrompt === null &&
3434
4206
  choicePicker === null &&
3435
4207
  copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
3436
4208
  permission === null &&
4209
+ textPrompt === null &&
3437
4210
  choicePicker === null &&
3438
4211
  copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
3439
- ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
4212
+ ? queuePause.outcome === 'paused'
4213
+ ? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
4214
+ : `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
3440
4215
  : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
3441
4216
  state === 'awaiting-permission' ||
3442
4217
  compacting ||
3443
- conversationMutation !== null, hidden: permission !== null || choicePicker !== null || copyPicker !== null,
4218
+ conversationMutation !== null ||
4219
+ externalEditorRequest !== null, hidden: permission !== null ||
4220
+ textPrompt !== null ||
4221
+ choicePicker !== null ||
4222
+ copyPicker !== null,
3444
4223
  // A turn is running, so Esc is the interrupt and not
3445
4224
  // the composer's clear.
3446
- escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
4225
+ 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)), userCommands: userCommands, mentionCandidates: mentionCandidates, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
3447
4226
  state === 'idle' &&
3448
4227
  conversationMutation === null &&
3449
4228
  !compacting &&
3450
4229
  permission === null &&
4230
+ textPrompt === null &&
3451
4231
  choicePicker === null &&
3452
4232
  copyPicker === null
3453
4233
  ? goalStatusLabel(goalStatus)
@@ -3455,23 +4235,27 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3455
4235
  ? 'forking conversation — input is paused'
3456
4236
  : conversationMutation === 'edit'
3457
4237
  ? 'branching before prompt — input is paused'
3458
- : conversationMutation === 'new'
3459
- ? 'starting a fresh conversation — input is paused'
3460
- : compacting
3461
- ? 'compacting conversation — input is paused'
3462
- : permission
3463
- ? hintForPhase(phase, state, session?.hasProvider === true)
3464
- : choicePicker
3465
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
3466
- : copyPicker
3467
- ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
3468
- : goalStatus && phase === 'ready' && state === 'idle'
3469
- ? undefined
3470
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
4238
+ : conversationMutation === 'archive'
4239
+ ? 'archiving conversation — input is paused'
4240
+ : conversationMutation === 'new'
4241
+ ? 'starting a fresh conversation — input is paused'
4242
+ : compacting
4243
+ ? 'compacting conversation — input is paused'
4244
+ : permission
4245
+ ? hintForPhase(phase, state, session?.hasProvider === true)
4246
+ : textPrompt
4247
+ ? 'name editor open — enter save · esc cancel'
4248
+ : choicePicker
4249
+ ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
4250
+ : copyPicker
4251
+ ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
4252
+ : goalStatus && phase === 'ready' && state === 'idle'
4253
+ ? undefined
4254
+ : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
3471
4255
  }
3472
4256
  function Banner({ version, session, permissionMode, cwd, }) {
3473
- const cols = process.stdout.columns ?? 80;
3474
- const wide = cols >= NAMZU_WORDMARK_MIN_WIDTH;
4257
+ const { columns } = useWindowSize();
4258
+ const wide = columns >= NAMZU_WORDMARK_MIN_WIDTH;
3475
4259
  const provider = session?.providerSummary;
3476
4260
  const model = session?.modelSummary;
3477
4261
  const home = process.env.HOME;
@@ -3490,8 +4274,11 @@ function ComposerFrame({ focus, hidden = false, children, }) {
3490
4274
  // Tool call label: the tool name title-cased, then its most identifying
3491
4275
  // argument — `Bash(ls -la)`, `Read(file.ts)`. A bare tool name in a transcript
3492
4276
  // of forty calls says nothing about which one this was.
3493
- function formatToolCall(toolName, summary) {
3494
- const display = toolName.length > 0 ? toolName[0]?.toUpperCase() + toolName.slice(1) : toolName;
4277
+ function formatToolCall(toolName, summary, standalone = false) {
4278
+ if (standalone)
4279
+ return summary;
4280
+ const words = toolName.replace(/[_-]+/g, ' ');
4281
+ const display = words.length > 0 ? words[0]?.toUpperCase() + words.slice(1) : words;
3495
4282
  return summary.length > 0 ? `${display}(${summary})` : display;
3496
4283
  }
3497
4284
  function hintForPhase(phase, state,