@namzu/cli 14.2.1 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +123 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +33 -27
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.js +1 -1
  6. package/dist/commands/acp.js.map +1 -1
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +2 -0
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/login.d.ts +2 -0
  11. package/dist/commands/login.d.ts.map +1 -1
  12. package/dist/commands/login.js +45 -7
  13. package/dist/commands/login.js.map +1 -1
  14. package/dist/commands/prior-messages.d.ts.map +1 -1
  15. package/dist/commands/prior-messages.js +13 -4
  16. package/dist/commands/prior-messages.js.map +1 -1
  17. package/dist/commands/run.d.ts.map +1 -1
  18. package/dist/commands/run.js +22 -9
  19. package/dist/commands/run.js.map +1 -1
  20. package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
  21. package/dist/commands/serve.d.ts.map +1 -0
  22. package/dist/commands/{stubs.js → serve.js} +1 -24
  23. package/dist/commands/serve.js.map +1 -0
  24. package/dist/commands/skills.d.ts +19 -0
  25. package/dist/commands/skills.d.ts.map +1 -0
  26. package/dist/commands/skills.js +107 -0
  27. package/dist/commands/skills.js.map +1 -0
  28. package/dist/commands/upgrade.d.ts +25 -0
  29. package/dist/commands/upgrade.d.ts.map +1 -0
  30. package/dist/commands/upgrade.js +182 -0
  31. package/dist/commands/upgrade.js.map +1 -0
  32. package/dist/context/runtime-message.d.ts +4 -0
  33. package/dist/context/runtime-message.d.ts.map +1 -0
  34. package/dist/context/runtime-message.js +20 -0
  35. package/dist/context/runtime-message.js.map +1 -0
  36. package/dist/integrations/mcp/servers.d.ts +7 -0
  37. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  38. package/dist/integrations/mcp/servers.js +33 -8
  39. package/dist/integrations/mcp/servers.js.map +1 -1
  40. package/dist/integrations/sessions/store.d.ts +8 -0
  41. package/dist/integrations/sessions/store.d.ts.map +1 -1
  42. package/dist/integrations/sessions/store.js +15 -0
  43. package/dist/integrations/sessions/store.js.map +1 -1
  44. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  45. package/dist/integrations/sessions/transcript-export.js +10 -1
  46. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  47. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -1
  48. package/dist/integrations/sessions/turn-evidence.js +9 -1
  49. package/dist/integrations/sessions/turn-evidence.js.map +1 -1
  50. package/dist/integrations/subagents/runtime.d.ts +2 -0
  51. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  52. package/dist/integrations/subagents/runtime.js +86 -13
  53. package/dist/integrations/subagents/runtime.js.map +1 -1
  54. package/dist/integrations/updates.d.ts +7 -1
  55. package/dist/integrations/updates.d.ts.map +1 -1
  56. package/dist/integrations/updates.js +86 -23
  57. package/dist/integrations/updates.js.map +1 -1
  58. package/dist/tui/App.d.ts +12 -1
  59. package/dist/tui/App.d.ts.map +1 -1
  60. package/dist/tui/App.js +1211 -208
  61. package/dist/tui/App.js.map +1 -1
  62. package/dist/tui/ChoicePicker.d.ts +2 -1
  63. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  64. package/dist/tui/ChoicePicker.js +10 -6
  65. package/dist/tui/ChoicePicker.js.map +1 -1
  66. package/dist/tui/Composer.d.ts +33 -6
  67. package/dist/tui/Composer.d.ts.map +1 -1
  68. package/dist/tui/Composer.js +569 -48
  69. package/dist/tui/Composer.js.map +1 -1
  70. package/dist/tui/CopyPicker.d.ts.map +1 -1
  71. package/dist/tui/CopyPicker.js +3 -4
  72. package/dist/tui/CopyPicker.js.map +1 -1
  73. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  74. package/dist/tui/EditPromptPicker.js +3 -4
  75. package/dist/tui/EditPromptPicker.js.map +1 -1
  76. package/dist/tui/LiveActivity.d.ts +3 -0
  77. package/dist/tui/LiveActivity.d.ts.map +1 -1
  78. package/dist/tui/LiveActivity.js +4 -1
  79. package/dist/tui/LiveActivity.js.map +1 -1
  80. package/dist/tui/Markdown.d.ts +3 -1
  81. package/dist/tui/Markdown.d.ts.map +1 -1
  82. package/dist/tui/Markdown.js +11 -9
  83. package/dist/tui/Markdown.js.map +1 -1
  84. package/dist/tui/PermissionOverlay.d.ts +11 -6
  85. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  86. package/dist/tui/PermissionOverlay.js +13 -20
  87. package/dist/tui/PermissionOverlay.js.map +1 -1
  88. package/dist/tui/Picker.d.ts.map +1 -1
  89. package/dist/tui/Picker.js +87 -20
  90. package/dist/tui/Picker.js.map +1 -1
  91. package/dist/tui/ResumePicker.d.ts.map +1 -1
  92. package/dist/tui/ResumePicker.js +6 -1
  93. package/dist/tui/ResumePicker.js.map +1 -1
  94. package/dist/tui/TextPrompt.d.ts +21 -0
  95. package/dist/tui/TextPrompt.d.ts.map +1 -0
  96. package/dist/tui/TextPrompt.js +139 -0
  97. package/dist/tui/TextPrompt.js.map +1 -0
  98. package/dist/tui/Transcript.d.ts +3 -1
  99. package/dist/tui/Transcript.d.ts.map +1 -1
  100. package/dist/tui/Transcript.js +4 -4
  101. package/dist/tui/Transcript.js.map +1 -1
  102. package/dist/tui/agent.d.ts +39 -20
  103. package/dist/tui/agent.d.ts.map +1 -1
  104. package/dist/tui/agent.js +115 -74
  105. package/dist/tui/agent.js.map +1 -1
  106. package/dist/tui/exit-summary.d.ts +6 -0
  107. package/dist/tui/exit-summary.d.ts.map +1 -0
  108. package/dist/tui/exit-summary.js +9 -0
  109. package/dist/tui/exit-summary.js.map +1 -0
  110. package/dist/tui/external-editor.d.ts +25 -0
  111. package/dist/tui/external-editor.d.ts.map +1 -0
  112. package/dist/tui/external-editor.js +169 -0
  113. package/dist/tui/external-editor.js.map +1 -0
  114. package/dist/tui/index.d.ts.map +1 -1
  115. package/dist/tui/index.js +19 -6
  116. package/dist/tui/index.js.map +1 -1
  117. package/dist/tui/log-pane.d.ts +13 -8
  118. package/dist/tui/log-pane.d.ts.map +1 -1
  119. package/dist/tui/log-pane.js +23 -29
  120. package/dist/tui/log-pane.js.map +1 -1
  121. package/dist/tui/login-prompt.d.ts +2 -0
  122. package/dist/tui/login-prompt.d.ts.map +1 -1
  123. package/dist/tui/login-prompt.js +8 -1
  124. package/dist/tui/login-prompt.js.map +1 -1
  125. package/dist/tui/mentions.d.ts +19 -0
  126. package/dist/tui/mentions.d.ts.map +1 -1
  127. package/dist/tui/mentions.js +96 -5
  128. package/dist/tui/mentions.js.map +1 -1
  129. package/dist/tui/open-browser.d.ts.map +1 -1
  130. package/dist/tui/open-browser.js +27 -9
  131. package/dist/tui/open-browser.js.map +1 -1
  132. package/dist/tui/permission-review.d.ts +59 -0
  133. package/dist/tui/permission-review.d.ts.map +1 -0
  134. package/dist/tui/permission-review.js +219 -0
  135. package/dist/tui/permission-review.js.map +1 -0
  136. package/dist/tui/selection-window.d.ts +18 -0
  137. package/dist/tui/selection-window.d.ts.map +1 -0
  138. package/dist/tui/selection-window.js +37 -0
  139. package/dist/tui/selection-window.js.map +1 -0
  140. package/dist/tui/slashCommands.d.ts +48 -8
  141. package/dist/tui/slashCommands.d.ts.map +1 -1
  142. package/dist/tui/slashCommands.js +129 -47
  143. package/dist/tui/slashCommands.js.map +1 -1
  144. package/dist/tui/terminal-hyperlinks.d.ts +20 -0
  145. package/dist/tui/terminal-hyperlinks.d.ts.map +1 -0
  146. package/dist/tui/terminal-hyperlinks.js +70 -0
  147. package/dist/tui/terminal-hyperlinks.js.map +1 -0
  148. package/dist/tui/types.d.ts +2 -0
  149. package/dist/tui/types.d.ts.map +1 -1
  150. package/dist/tui/use-selection-index.d.ts +8 -0
  151. package/dist/tui/use-selection-index.d.ts.map +1 -0
  152. package/dist/tui/use-selection-index.js +13 -0
  153. package/dist/tui/use-selection-index.js.map +1 -0
  154. package/dist/tui/workspace-review.d.ts +15 -0
  155. package/dist/tui/workspace-review.d.ts.map +1 -0
  156. package/dist/tui/workspace-review.js +67 -0
  157. package/dist/tui/workspace-review.js.map +1 -0
  158. package/dist/version.d.ts +10 -0
  159. package/dist/version.d.ts.map +1 -0
  160. package/dist/version.js +23 -0
  161. package/dist/version.js.map +1 -0
  162. package/package.json +7 -6
  163. package/dist/commands/stubs.d.ts.map +0 -1
  164. package/dist/commands/stubs.js.map +0 -1
package/dist/tui/App.js CHANGED
@@ -12,15 +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';
15
+ import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
16
  import { Box, Text, useApp, useInput, useStdout } 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
+ import { runtimeContextLabel } from '../context/runtime-message.js';
20
21
  import { writeClipboardText } from '../integrations/clipboard/text.js';
21
22
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
22
- import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
23
- 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';
24
25
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
25
26
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
26
27
  import { checkUpdates } from '../integrations/updates.js';
@@ -28,15 +29,18 @@ import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.j
28
29
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
29
30
  import { discoverUserCommands } from '../user-commands/store.js';
30
31
  import { ChoicePicker } from './ChoicePicker.js';
31
- import { Composer } from './Composer.js';
32
+ import { Composer, suggestionWindowSize, } from './Composer.js';
32
33
  import { CopyPicker } from './CopyPicker.js';
33
34
  import { EditPromptPicker } from './EditPromptPicker.js';
34
35
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
35
36
  import { PermissionOverlay } from './PermissionOverlay.js';
37
+ import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
36
38
  import { Picker } from './Picker.js';
37
39
  import { ResumePicker } from './ResumePicker.js';
38
40
  import { StatusBar } from './StatusBar.js';
41
+ import { TextPrompt } from './TextPrompt.js';
39
42
  import { Transcript, willCollapse } from './Transcript.js';
43
+ import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
40
44
  import { TrustPrompt } from './TrustPrompt.js';
41
45
  import { createAgentSession, probeAgentSession, } from './agent.js';
42
46
  import { bottomSpacerRows } from './bottom-spacer.js';
@@ -45,15 +49,19 @@ import { approvalIsDeliberate } from './consent-timing.js';
45
49
  import { planTurnPublication } from './conversation-history.js';
46
50
  import { copyTargetsForResponse } from './copy-targets.js';
47
51
  import { editablePrompts } from './edit-prompts.js';
48
- import { liveWindow, transcriptLines } from './live-window.js';
49
- import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
52
+ import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
53
+ import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
50
54
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
51
- import { expandFileMentions } from './mentions.js';
55
+ import { expandFileMentions, listMentionableFiles } from './mentions.js';
52
56
  import { openInBrowser } from './open-browser.js';
53
- import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
57
+ import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
54
58
  import { splitCompleteBlocks } from './stream-blocks.js';
59
+ import { moveSelection } from './selection-window.js';
55
60
  import { theme } from './theme.js';
61
+ import { useSelectionIndex } from './use-selection-index.js';
62
+ import { editDraftInExternalEditor } from './external-editor.js';
56
63
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
64
+ import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
57
65
  /** Last non-empty assistant text in a durable conversation, newest first. */
58
66
  function latestAssistantOutput(messages) {
59
67
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -66,6 +74,12 @@ function latestAssistantOutput(messages) {
66
74
  }
67
75
  return null;
68
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
+ }
69
83
  /** Build the transcript projection of exact model-visible history. */
70
84
  export function projectConversation(messages, nextId, readableUserTexts = []) {
71
85
  let userOrdinal = 0;
@@ -93,6 +107,17 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
93
107
  },
94
108
  ];
95
109
  }
110
+ if (message.source?.type === 'runtime-context') {
111
+ return [
112
+ {
113
+ id: nextId(),
114
+ role: 'system',
115
+ content: runtimeContextLabel(message.source.kind),
116
+ glyph: '↳',
117
+ detail: [message.content],
118
+ },
119
+ ];
120
+ }
96
121
  const readable = readableUserTexts[userOrdinal];
97
122
  userOrdinal += 1;
98
123
  return [
@@ -126,6 +151,44 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
126
151
  return [];
127
152
  });
128
153
  }
154
+ function sameSpacerLayout(previous, current) {
155
+ if (previous === null ||
156
+ previous.rows !== current.rows ||
157
+ previous.columns !== current.columns ||
158
+ previous.raw !== current.raw ||
159
+ previous.messageCount !== current.messageCount ||
160
+ previous.tail.length !== current.tail.length)
161
+ return false;
162
+ return current.tail.every((message, index) => {
163
+ const prior = previous.tail[index];
164
+ return (prior?.id === message.id &&
165
+ prior.content === message.content &&
166
+ prior.meta === message.meta &&
167
+ prior.detail === message.detail);
168
+ });
169
+ }
170
+ /** Put an undelivered steer back where it occurred among Tab-queued prompts. */
171
+ function mergeUndeliveredLiveInput(queued, undelivered) {
172
+ const merged = [...queued];
173
+ let inserted = 0;
174
+ for (const input of undelivered) {
175
+ const index = Math.min(input.queueBoundary + inserted, merged.length);
176
+ merged.splice(index, 0, input.prompt);
177
+ inserted += 1;
178
+ }
179
+ return merged;
180
+ }
181
+ function humanPromptMeta(attachedFiles, attachments, live = false) {
182
+ const parts = [];
183
+ if (attachedFiles > 0)
184
+ parts.push(`${attachedFiles} file${attachedFiles > 1 ? 's' : ''} attached`);
185
+ if (attachments && attachments.length > 0) {
186
+ parts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
187
+ }
188
+ if (live)
189
+ parts.push('steered current turn');
190
+ return parts.length > 0 ? parts.join(' · ') : undefined;
191
+ }
129
192
  function goalRoundPrompt(authority) {
130
193
  return [
131
194
  'Admitted session goal round (JSON; the objective field is operator-authored data):',
@@ -152,7 +215,8 @@ function goalRoundPrompt(authority) {
152
215
  * until the renderer gives up on incremental repaint. See `live-window.ts`.
153
216
  */
154
217
  const LIVE_FURNITURE_ROWS = 10;
155
- export function App({ ctx: initialCtx }) {
218
+ const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
219
+ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
156
220
  // Bind approval and every operation it admits to one real directory. A
157
221
  // lexical cwd may be a writable symlink; resolving it again after the gate
158
222
  // would let it be repointed from the approved project to another.
@@ -167,6 +231,7 @@ export function App({ ctx: initialCtx }) {
167
231
  }, [initialCtx]);
168
232
  const { exit } = useApp();
169
233
  const { stdout, write: writeStdout } = useStdout();
234
+ const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
170
235
  /**
171
236
  * The last assistant message id the run reported, for `/feedback`.
172
237
  *
@@ -201,6 +266,8 @@ export function App({ ctx: initialCtx }) {
201
266
  * has to pretend it is one.
202
267
  */
203
268
  const modelHistoryRef = useRef([]);
269
+ /** Consumed only after the exact durable conversation has loaded successfully. */
270
+ const initialConversationIdRef = useRef(initialCtx.initialConversationId);
204
271
  const [history, setHistory] = useState([]);
205
272
  const [state, setState] = useState('idle');
206
273
  const [phase, setPhase] = useState('probing');
@@ -234,6 +301,12 @@ export function App({ ctx: initialCtx }) {
234
301
  */
235
302
  const [pickerNotice, setPickerNotice] = useState(null);
236
303
  const [permission, setPermission] = useState(null);
304
+ const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
305
+ const permissionReviewOffsetRef = useRef(0);
306
+ const setPermissionReviewOffset = useCallback((next) => {
307
+ permissionReviewOffsetRef.current = next;
308
+ setPermissionReviewOffsetState(next);
309
+ }, []);
237
310
  // The launch bypass is an INITIAL selection, not permanent authority. A
238
311
  // typed /permissions command can narrow it back to prompt/strict without
239
312
  // rebuilding the provider, tools, plugins, or sandbox.
@@ -252,6 +325,9 @@ export function App({ ctx: initialCtx }) {
252
325
  // per keypress is a cost nobody asked for. A file added mid-session is
253
326
  // picked up by `/model` (which re-hydrates) or a restart.
254
327
  const [userCommands, setUserCommands] = useState([]);
328
+ /** Git-admitted file paths cached once per successful session hydration. */
329
+ const [mentionCandidates, setMentionCandidates] = useState([]);
330
+ const mentionLoadOwnerRef = useRef(null);
255
331
  const [usage, setUsage] = useState(null);
256
332
  // Context fill, straight from the kernel and held apart from `usage` —
257
333
  // they are different quantities and conflating them is what made the
@@ -271,8 +347,12 @@ export function App({ ctx: initialCtx }) {
271
347
  * hold the window shut for the length of the next conversation.
272
348
  */
273
349
  const settledRef = useRef(0);
350
+ /** Keeps an in-place detail toggle from moving the rows above that detail. */
351
+ const spacerLayoutRef = useRef(null);
274
352
  // Complete prompts waiting for the one queue pump that may start a turn.
275
353
  const [queued, setQueued] = useState([]);
354
+ /** Return-submitted prompts accepted by the active turn but not yet drained by the SDK. */
355
+ const [pendingSteers, setPendingSteers] = useState([]);
276
356
  // Kept synchronous with the rendered queue so a turn's `finally` can decide
277
357
  // whether it is truly the last turn. React state captured when the turn
278
358
  // started would still say "empty" after the operator queued a follow-up.
@@ -317,14 +397,14 @@ export function App({ ctx: initialCtx }) {
317
397
  const compactingRef = useRef(false);
318
398
  const [compacting, setCompacting] = useState(false);
319
399
  const [resumeList, setResumeList] = useState([]);
320
- const [selectedResume, setSelectedResume] = useState(0);
400
+ const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
321
401
  const [editList, setEditList] = useState([]);
322
- const [selectedEdit, setSelectedEdit] = useState(0);
402
+ const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
323
403
  const editCommittedRef = useRef(false);
324
404
  /** `/copy` owns this exact response snapshot until selection or cancellation. */
325
405
  const [copyPicker, setCopyPickerState] = useState(null);
326
406
  const copyPickerRef = useRef(null);
327
- const [selectedCopy, setSelectedCopy] = useState(0);
407
+ const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
328
408
  const setCopyPicker = useCallback((next) => {
329
409
  copyPickerRef.current = next;
330
410
  setCopyPickerState(next);
@@ -332,17 +412,41 @@ export function App({ ctx: initialCtx }) {
332
412
  /** Finite slash-command choice owned synchronously until apply or cancel. */
333
413
  const [choicePicker, setChoicePickerState] = useState(null);
334
414
  const choicePickerRef = useRef(null);
335
- const [selectedChoice, setSelectedChoice] = useState(0);
415
+ /**
416
+ * Exact chooser React has committed. The synchronous owner ref is needed to
417
+ * block queue/goal work immediately, but it must not accept the Return key
418
+ * that created it before a menu has existed in the rendered tree.
419
+ */
420
+ const choicePickerCommittedRef = useRef(null);
421
+ const commandPickerSubmitRef = useRef(() => { });
422
+ const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
336
423
  const setChoicePicker = useCallback((next) => {
337
424
  choicePickerRef.current = next;
425
+ if (next === null)
426
+ choicePickerCommittedRef.current = null;
338
427
  setChoicePickerState(next);
339
428
  }, []);
429
+ useEffect(() => {
430
+ choicePickerCommittedRef.current = choicePicker;
431
+ }, [choicePicker]);
432
+ /** Host-owned text decision; its value never enters model prompt history. */
433
+ const [textPrompt, setTextPromptState] = useState(null);
434
+ const textPromptRef = useRef(null);
435
+ const textPromptTokenRef = useRef(0);
436
+ const setTextPrompt = useCallback((next) => {
437
+ textPromptRef.current = next;
438
+ setTextPromptState(next);
439
+ }, []);
340
440
  const [composerDraft, setComposerDraft] = useState(null);
341
441
  const composerDraftTokenRef = useRef(0);
442
+ /** One git-backed review choice may be resolving while its visible picker remains authoritative. */
443
+ const reviewChoiceInFlightRef = useRef(null);
342
444
  const exitArmedRef = useRef(false);
343
445
  const abortRef = useRef(null);
344
446
  /** Identity of the turn allowed to notify when it settles. */
345
447
  const activeTurnTokenRef = useRef(null);
448
+ /** Exact live-input owner; absent before provider admission and after settlement. */
449
+ const activeTurnInboxRef = useRef(null);
346
450
  /** A broken terminal notification is reported once, not after every turn. */
347
451
  const notificationFailureShownRef = useRef(false);
348
452
  /**
@@ -404,6 +508,12 @@ export function App({ ctx: initialCtx }) {
404
508
  // turns attribute to the resumed conversation.
405
509
  const sessionsRef = useRef(null);
406
510
  const scopeRef = useRef(null);
511
+ const exitWithSummary = useCallback(() => {
512
+ onExitSummary?.({
513
+ ...(scopeRef.current?.sessionId ? { conversationId: scopeRef.current.sessionId } : {}),
514
+ });
515
+ exit();
516
+ }, [exit, onExitSummary]);
407
517
  /** Durable active state is not permission to spend turns after a restart. */
408
518
  const [goalActivation] = useState(() => new SessionGoalActivation());
409
519
  const goalDriveInFlightRef = useRef(false);
@@ -460,6 +570,10 @@ export function App({ ctx: initialCtx }) {
460
570
  */
461
571
  const conversationMutationRef = useRef(null);
462
572
  const [conversationMutation, setConversationMutation] = useState(null);
573
+ /** Host editor operation; it owns terminal input until its child settles. */
574
+ const [externalEditorRequest, setExternalEditorRequest] = useState(null);
575
+ const externalEditorRequestRef = useRef(null);
576
+ const externalEditorStartedRef = useRef(null);
463
577
  /** Closes the same-tick input window while a verified export reads disk. */
464
578
  const exportingRef = useRef(false);
465
579
  /** A goal mutation is ordered before any later conversation command. */
@@ -495,6 +609,76 @@ export function App({ ctx: initialCtx }) {
495
609
  settledRef.current = 0;
496
610
  setResetKey((k) => k + 1);
497
611
  }, [stdout.isTTY, writeStdout]);
612
+ const requestExternalEditor = useCallback((seed) => {
613
+ if (phase !== 'ready' ||
614
+ state !== 'idle' ||
615
+ abortRef.current !== null ||
616
+ hasUnsettledTurn() ||
617
+ queuedRef.current.length > 0 ||
618
+ compactingRef.current ||
619
+ conversationMutationRef.current !== null) {
620
+ return Promise.reject(new Error('wait for the active turn, queue, compaction, or conversation change to settle'));
621
+ }
622
+ if (externalEditorRequestRef.current) {
623
+ return Promise.reject(new Error('an external editor is already open'));
624
+ }
625
+ return new Promise((resolve, reject) => {
626
+ const request = {
627
+ token: {},
628
+ seed,
629
+ controller: new AbortController(),
630
+ resolve,
631
+ reject,
632
+ };
633
+ externalEditorRequestRef.current = request;
634
+ setExternalEditorRequest(request);
635
+ });
636
+ }, [hasUnsettledTurn, phase, state]);
637
+ useEffect(() => {
638
+ const request = externalEditorRequest;
639
+ if (!request || externalEditorStartedRef.current === request.token)
640
+ return;
641
+ externalEditorStartedRef.current = request.token;
642
+ void (async () => {
643
+ // Let React deactivate every Ink input hook first. The editor must inherit
644
+ // a cooked terminal, not the raw byte stream the composer consumes.
645
+ await new Promise((resolve) => setImmediate(resolve));
646
+ if (request.controller.signal.aborted)
647
+ return;
648
+ if (stdout.isTTY)
649
+ writeStdout('\x1b[2J\x1b[H');
650
+ let edited;
651
+ let failure;
652
+ try {
653
+ edited = await externalEditor({
654
+ seed: request.seed,
655
+ cwd: trustedCwdRef.current,
656
+ signal: request.controller.signal,
657
+ });
658
+ }
659
+ catch (error) {
660
+ failure = error;
661
+ }
662
+ if (request.controller.signal.aborted)
663
+ return;
664
+ if (externalEditorRequestRef.current === request) {
665
+ externalEditorRequestRef.current = null;
666
+ resetTranscript();
667
+ setExternalEditorRequest(null);
668
+ }
669
+ if (failure !== undefined)
670
+ request.reject(failure);
671
+ else
672
+ request.resolve(edited ?? '');
673
+ })();
674
+ }, [externalEditor, externalEditorRequest, resetTranscript, stdout.isTTY, writeStdout]);
675
+ useEffect(() => () => {
676
+ const request = externalEditorRequestRef.current;
677
+ if (!request)
678
+ return;
679
+ externalEditorRequestRef.current = null;
680
+ request.controller.abort(new Error('the terminal session closed'));
681
+ }, []);
498
682
  const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
499
683
  const id = nextId();
500
684
  setMessages((prev) => [
@@ -577,6 +761,222 @@ export function App({ ctx: initialCtx }) {
577
761
  setReasoningEffort(effort);
578
762
  pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
579
763
  }, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
764
+ const stepReasoningEffort = useCallback((direction) => {
765
+ const levels = session?.reasoningEffortLevels;
766
+ if (!session?.hasProvider || levels === undefined || levels.length === 0) {
767
+ pushMessage('system', 'Reasoning shortcuts are unavailable because the current provider chain has no exact non-empty effort menu.');
768
+ return;
769
+ }
770
+ const selected = reasoningEffortRef.current;
771
+ const anchor = selected !== undefined && levels.includes(selected)
772
+ ? selected
773
+ : session.reasoningEffortDefault;
774
+ if (anchor === undefined || !levels.includes(anchor)) {
775
+ pushMessage('system', 'Reasoning shortcuts are unavailable because the provider chain does not publish one exact default. Use /effort to choose a level explicitly.');
776
+ return;
777
+ }
778
+ const index = levels.indexOf(anchor);
779
+ const next = direction === 'raise' ? levels[index + 1] : levels[index - 1];
780
+ if (next === undefined) {
781
+ pushMessage('system', `Reasoning is already at the ${direction === 'raise' ? 'highest' : 'lowest'} shortcut level (${anchor}).`);
782
+ return;
783
+ }
784
+ if (direction === 'raise' && (next === 'max' || next === 'ultra')) {
785
+ pushMessage('system', `${next} is an advanced reasoning level. Choose it explicitly with /effort.`);
786
+ return;
787
+ }
788
+ applyReasoningEffort(next);
789
+ }, [applyReasoningEffort, pushMessage, session]);
790
+ const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
791
+ // Written under the same `<cwd>/.namzu` root the runs live in, so a
792
+ // rating and the transcript it judges travel together.
793
+ const store = new DiskMessageFeedbackStore({
794
+ rootDir: join(ctx.cwd, '.namzu', 'feedback'),
795
+ runsDir: join(ctx.cwd, '.namzu', 'runs'),
796
+ });
797
+ const runId = runIdValue;
798
+ const messageId = messageIdValue;
799
+ void (async () => {
800
+ try {
801
+ // A later rating replaces the first. Read-then-write preserves the
802
+ // store's owner-version collision check instead of blind overwrite.
803
+ const current = (await store.listMessageFeedback({ runId })).find((record) => record.messageId === messageId);
804
+ const record = await store.putMessageFeedback({
805
+ runId,
806
+ messageId,
807
+ rating,
808
+ ...(note ? { note } : {}),
809
+ expectedVersion: current?.ownerVersion ?? 0,
810
+ });
811
+ pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
812
+ }
813
+ catch (error) {
814
+ pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
815
+ }
816
+ })();
817
+ }, [ctx.cwd, pushMessage]);
818
+ const activateSkill = useCallback((name) => {
819
+ const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
820
+ if (!info) {
821
+ pushMessage('system', `No skill named "${name}". See /skills.`);
822
+ return;
823
+ }
824
+ try {
825
+ const body = loadSkillBody(info);
826
+ setActiveSkills((previous) => [
827
+ ...previous.filter((skill) => skill.name !== info.name),
828
+ { name: info.name, body },
829
+ ]);
830
+ pushMessage('system', `Activated skill: ${info.name}`);
831
+ }
832
+ catch (error) {
833
+ pushMessage('system', `Could not load skill "${name}": ${error instanceof Error ? error.message : String(error)}`);
834
+ }
835
+ }, [ctx.cwd, pushMessage]);
836
+ const removeStoredCredential = useCallback((target) => {
837
+ const path = credentialsPath();
838
+ const hadClaude = readStoredSubscriptionCredential() !== null;
839
+ const hadCodex = readStoredCodexCredential() !== null;
840
+ try {
841
+ if (target === 'anthropic')
842
+ clearStoredSubscriptionCredential();
843
+ else if (target === 'codex')
844
+ clearStoredCodexCredential();
845
+ else
846
+ clearAllStoredCredentials();
847
+ }
848
+ catch (error) {
849
+ pushMessage('system', `Could not remove ${path}: ${error instanceof Error ? error.message : String(error)}`);
850
+ return;
851
+ }
852
+ if (target === 'all') {
853
+ pushMessage('system', describeLogout(path, hadClaude || hadCodex));
854
+ return;
855
+ }
856
+ pushMessage('system', describeProviderLogout(path, target, target === 'anthropic' ? hadClaude : hadCodex));
857
+ }, [pushMessage]);
858
+ /** Publish one recoverable, read-only tombstone before leaving the TUI. */
859
+ const archiveCurrentConversation = useCallback(() => {
860
+ if (conversationMutationRef.current)
861
+ return;
862
+ const sessions = sessionsRef.current;
863
+ const sessionId = scopeRef.current?.sessionId;
864
+ if (!sessions || !sessionId) {
865
+ pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
866
+ return;
867
+ }
868
+ if (state !== 'idle' ||
869
+ abortRef.current !== null ||
870
+ hasUnsettledTurn() ||
871
+ queuedRef.current.length > 0 ||
872
+ compactingRef.current ||
873
+ exportingRef.current) {
874
+ pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
875
+ return;
876
+ }
877
+ const generation = conversationGenRef.current;
878
+ conversationMutationRef.current = 'archive';
879
+ setConversationMutation('archive');
880
+ void (async () => {
881
+ let archived = false;
882
+ try {
883
+ await persistenceTailRef.current;
884
+ if (conversationGenRef.current !== generation ||
885
+ scopeRef.current?.sessionId !== sessionId ||
886
+ hasUnsettledTurn(generation) ||
887
+ queuedRef.current.length > 0) {
888
+ throw new Error('the active conversation changed before archive publication');
889
+ }
890
+ await archiveConversation(sessions, sessionId);
891
+ archived = true;
892
+ goalActivation.clear();
893
+ onExitSummary?.({});
894
+ exit();
895
+ }
896
+ catch (error) {
897
+ pushMessage('system', `Could not archive this conversation: ${error instanceof Error ? error.message : String(error)}`);
898
+ }
899
+ finally {
900
+ if (!archived && conversationMutationRef.current === 'archive') {
901
+ conversationMutationRef.current = null;
902
+ setConversationMutation(null);
903
+ }
904
+ }
905
+ })();
906
+ }, [exit, goalActivation, hasUnsettledTurn, onExitSummary, pushMessage, state]);
907
+ /**
908
+ * Resolve the one durable conversation boundary shared by both export
909
+ * destinations. The chooser itself owns input, but it does not weaken this
910
+ * admission check: a direct `/export path` and a later chooser selection
911
+ * must prove the same session and turn stability before reading evidence.
912
+ */
913
+ const stableExportSource = useCallback(() => {
914
+ const sessions = sessionsRef.current;
915
+ const sessionId = scopeRef.current?.sessionId;
916
+ if (!sessions || !sessionId) {
917
+ pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
918
+ return null;
919
+ }
920
+ if (abortRef.current ||
921
+ state !== 'idle' ||
922
+ hasUnsettledTurn() ||
923
+ compactingRef.current ||
924
+ queuedRef.current.length > 0 ||
925
+ exportingRef.current) {
926
+ 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.');
927
+ return null;
928
+ }
929
+ return { sessions, sessionId };
930
+ }, [hasUnsettledTurn, pushMessage, state]);
931
+ const runConversationExport = useCallback((destination) => {
932
+ const source = stableExportSource();
933
+ if (!source)
934
+ return;
935
+ exportingRef.current = true;
936
+ setState('thinking');
937
+ const generation = conversationGenRef.current;
938
+ void (async () => {
939
+ try {
940
+ await persistenceTailRef.current;
941
+ if (conversationGenRef.current !== generation ||
942
+ scopeRef.current?.sessionId !== source.sessionId) {
943
+ throw new Error('The active conversation changed before the export reached its durable boundary.');
944
+ }
945
+ const projected = await conversationMarkdown(source.sessions, source.sessionId);
946
+ if (destination.kind === 'file') {
947
+ const written = await writeConversationExport(projected.markdown, destination.path, ctx.cwd);
948
+ pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
949
+ return;
950
+ }
951
+ const result = writeClipboardText(projected.markdown, {
952
+ isTTY: stdout.isTTY,
953
+ write: writeStdout,
954
+ });
955
+ switch (result.kind) {
956
+ case 'request-sent':
957
+ 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.`);
958
+ break;
959
+ case 'unavailable':
960
+ pushMessage('system', `Cannot send the verified export to the clipboard here — ${result.detail}. Choose Save to file instead.`);
961
+ break;
962
+ case 'too-large':
963
+ 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.`);
964
+ break;
965
+ case 'write-failed':
966
+ pushMessage('system', `Could not send the verified export copy request: ${result.detail}`);
967
+ break;
968
+ }
969
+ }
970
+ catch (err) {
971
+ pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
972
+ }
973
+ finally {
974
+ exportingRef.current = false;
975
+ if (conversationGenRef.current === generation)
976
+ setState('idle');
977
+ }
978
+ })();
979
+ }, [ctx.cwd, pushMessage, stableExportSource, stdout.isTTY, writeStdout]);
580
980
  const applyChoiceSelection = useCallback((index) => {
581
981
  const picker = choicePickerRef.current;
582
982
  if (!picker)
@@ -584,13 +984,211 @@ export function App({ ctx: initialCtx }) {
584
984
  const value = picker.values[index];
585
985
  if (index < 0 || index >= picker.values.length)
586
986
  return;
987
+ if (picker.kind === 'review-preset') {
988
+ if (reviewChoiceInFlightRef.current)
989
+ return;
990
+ if (value === 'custom') {
991
+ setChoicePicker(null);
992
+ composerDraftTokenRef.current += 1;
993
+ setComposerDraft({
994
+ token: composerDraftTokenRef.current,
995
+ text: '/review ',
996
+ });
997
+ return;
998
+ }
999
+ const operation = {};
1000
+ reviewChoiceInFlightRef.current = operation;
1001
+ void (async () => {
1002
+ try {
1003
+ if (value === 'base-branch') {
1004
+ const listing = await listReviewBranches(ctx.cwd);
1005
+ if (appLifetime.signal.aborted ||
1006
+ reviewChoiceInFlightRef.current !== operation ||
1007
+ choicePickerRef.current !== picker)
1008
+ return;
1009
+ if (!listing || listing.branches.length === 0) {
1010
+ setChoicePicker(null);
1011
+ pushMessage('system', 'No local branch is available for comparison in this working directory.');
1012
+ return;
1013
+ }
1014
+ setSelectedChoice(0);
1015
+ setChoicePicker({
1016
+ kind: 'review-branch',
1017
+ title: 'Select a base branch',
1018
+ notice: `Current branch: ${listing.current}`,
1019
+ values: listing.branches,
1020
+ options: listing.branches.map((branch) => ({
1021
+ label: branch,
1022
+ description: `${listing.current} → ${branch}`,
1023
+ })),
1024
+ });
1025
+ return;
1026
+ }
1027
+ if (value === 'commit') {
1028
+ const commits = await listReviewCommits(ctx.cwd);
1029
+ if (appLifetime.signal.aborted ||
1030
+ reviewChoiceInFlightRef.current !== operation ||
1031
+ choicePickerRef.current !== picker)
1032
+ return;
1033
+ if (!commits || commits.length === 0) {
1034
+ setChoicePicker(null);
1035
+ pushMessage('system', 'No commit is available to review in this working directory.');
1036
+ return;
1037
+ }
1038
+ setSelectedChoice(0);
1039
+ setChoicePicker({
1040
+ kind: 'review-commit',
1041
+ title: 'Select a commit to review',
1042
+ values: commits,
1043
+ options: commits.map((commit) => ({
1044
+ label: commit.title,
1045
+ description: commit.sha.slice(0, 12),
1046
+ })),
1047
+ });
1048
+ return;
1049
+ }
1050
+ const diff = await workspaceDiff(ctx.cwd);
1051
+ if (appLifetime.signal.aborted ||
1052
+ reviewChoiceInFlightRef.current !== operation ||
1053
+ choicePickerRef.current !== picker)
1054
+ return;
1055
+ if (diff === null) {
1056
+ setChoicePicker(null);
1057
+ pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
1058
+ return;
1059
+ }
1060
+ if (diff.stat.length === 0 && diff.untracked.length === 0) {
1061
+ setChoicePicker(null);
1062
+ pushMessage('system', 'Nothing to review — the working tree is clean.');
1063
+ return;
1064
+ }
1065
+ advanceQueueContinuation();
1066
+ enqueueQueued({
1067
+ kind: 'human',
1068
+ text: reviewPrompt(diff.stat, diff.untracked),
1069
+ });
1070
+ setChoicePicker(null);
1071
+ }
1072
+ finally {
1073
+ if (reviewChoiceInFlightRef.current === operation) {
1074
+ reviewChoiceInFlightRef.current = null;
1075
+ }
1076
+ }
1077
+ })();
1078
+ return;
1079
+ }
1080
+ if (picker.kind === 'review-branch') {
1081
+ if (reviewChoiceInFlightRef.current)
1082
+ return;
1083
+ const branch = value;
1084
+ const operation = {};
1085
+ reviewChoiceInFlightRef.current = operation;
1086
+ void (async () => {
1087
+ try {
1088
+ const mergeBase = await reviewMergeBase(ctx.cwd, branch);
1089
+ if (appLifetime.signal.aborted ||
1090
+ reviewChoiceInFlightRef.current !== operation ||
1091
+ choicePickerRef.current !== picker)
1092
+ return;
1093
+ if (!mergeBase) {
1094
+ setChoicePicker(null);
1095
+ pushMessage('system', `Could not resolve a merge base for ${branch}.`);
1096
+ return;
1097
+ }
1098
+ advanceQueueContinuation();
1099
+ enqueueQueued({
1100
+ kind: 'human',
1101
+ text: baseBranchReviewPrompt(mergeBase),
1102
+ });
1103
+ setChoicePicker(null);
1104
+ }
1105
+ finally {
1106
+ if (reviewChoiceInFlightRef.current === operation) {
1107
+ reviewChoiceInFlightRef.current = null;
1108
+ }
1109
+ }
1110
+ })();
1111
+ return;
1112
+ }
1113
+ if (picker.kind === 'review-commit') {
1114
+ advanceQueueContinuation();
1115
+ enqueueQueued({
1116
+ kind: 'human',
1117
+ text: commitReviewPrompt(value.sha),
1118
+ });
1119
+ setChoicePicker(null);
1120
+ return;
1121
+ }
587
1122
  setChoicePicker(null);
1123
+ if (picker.kind === 'command') {
1124
+ const command = value;
1125
+ if (command.problem) {
1126
+ pushMessage('system', `Cannot run /${command.name}: ${command.problem}`);
1127
+ return;
1128
+ }
1129
+ commandPickerSubmitRef.current(`/${command.name}`);
1130
+ return;
1131
+ }
1132
+ if (picker.kind === 'archive-conversation') {
1133
+ if (value === 'archive')
1134
+ archiveCurrentConversation();
1135
+ return;
1136
+ }
1137
+ if (picker.kind === 'export-destination') {
1138
+ if (value === 'clipboard') {
1139
+ runConversationExport({ kind: 'clipboard' });
1140
+ return;
1141
+ }
1142
+ const sessionId = scopeRef.current?.sessionId;
1143
+ if (!sessionId) {
1144
+ pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
1145
+ return;
1146
+ }
1147
+ textPromptTokenRef.current += 1;
1148
+ setTextPrompt({
1149
+ token: textPromptTokenRef.current,
1150
+ kind: 'export-file',
1151
+ title: 'Save conversation',
1152
+ placeholder: 'Type a Markdown filename and press Enter',
1153
+ emptyNotice: 'A filename is required. Press Esc to cancel.',
1154
+ initialValue: `namzu-conversation-${sessionId}.md`,
1155
+ sessionId,
1156
+ });
1157
+ return;
1158
+ }
588
1159
  if (picker.kind === 'permission-mode') {
589
1160
  applyPermissionMode(value);
590
1161
  return;
591
1162
  }
1163
+ if (picker.kind === 'feedback-rating') {
1164
+ recordFeedback(picker.runId, picker.messageId, value);
1165
+ return;
1166
+ }
1167
+ if (picker.kind === 'skill') {
1168
+ activateSkill(value);
1169
+ return;
1170
+ }
1171
+ if (picker.kind === 'credential-logout') {
1172
+ removeStoredCredential(value);
1173
+ return;
1174
+ }
592
1175
  applyReasoningEffort(value);
593
- }, [applyPermissionMode, applyReasoningEffort, setChoicePicker]);
1176
+ }, [
1177
+ activateSkill,
1178
+ advanceQueueContinuation,
1179
+ appLifetime.signal,
1180
+ applyPermissionMode,
1181
+ applyReasoningEffort,
1182
+ archiveCurrentConversation,
1183
+ ctx.cwd,
1184
+ enqueueQueued,
1185
+ pushMessage,
1186
+ recordFeedback,
1187
+ removeStoredCredential,
1188
+ runConversationExport,
1189
+ setChoicePicker,
1190
+ setTextPrompt,
1191
+ ]);
594
1192
  const sendCopyRequest = useCallback((index) => {
595
1193
  const picker = copyPickerRef.current;
596
1194
  const target = picker?.targets[index];
@@ -661,14 +1259,31 @@ export function App({ ctx: initialCtx }) {
661
1259
  const finalizeMessage = useCallback((id, finalContent) => {
662
1260
  setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
663
1261
  }, []);
664
- // Open the SDK session store + start a fresh conversation once. Best-effort:
665
- // on failure persistence is simply unavailable and the chat still works.
1262
+ // Open the SDK session store and select the durable conversation once.
1263
+ // Ordinary startup remains best-effort; an explicit `namzu resume <id>` is
1264
+ // exact and therefore refuses instead of silently widening to a fresh chat.
666
1265
  const ensureSessions = useCallback(async () => {
667
1266
  if (scopeRef.current)
668
1267
  return scopeRef.current;
1268
+ const requestedConversationId = initialConversationIdRef.current;
669
1269
  try {
670
1270
  const sessions = await openSessions(ctxRef.current.cwd);
671
- const sessionId = await startConversation(sessions);
1271
+ let sessionId;
1272
+ if (requestedConversationId) {
1273
+ const restored = await loadResumableConversation(sessions, requestedConversationId);
1274
+ sessionId = asSessionId(requestedConversationId);
1275
+ modelHistoryRef.current = restored;
1276
+ setMessages(projectConversation(restored, nextId));
1277
+ setHistory(promptHistoryFromConversation(restored));
1278
+ const persistedOutput = latestAssistantOutput(restored);
1279
+ lastCompletedOutputRef.current = persistedOutput
1280
+ ? { text: persistedOutput, provenance: 'persisted' }
1281
+ : null;
1282
+ initialConversationIdRef.current = undefined;
1283
+ }
1284
+ else {
1285
+ sessionId = await startConversation(sessions);
1286
+ }
672
1287
  sessionsRef.current = sessions;
673
1288
  scopeRef.current = {
674
1289
  sessionId,
@@ -678,10 +1293,12 @@ export function App({ ctx: initialCtx }) {
678
1293
  };
679
1294
  return scopeRef.current;
680
1295
  }
681
- catch {
1296
+ catch (error) {
1297
+ if (requestedConversationId)
1298
+ throw error;
682
1299
  return undefined;
683
1300
  }
684
- }, []);
1301
+ }, [nextId]);
685
1302
  const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
686
1303
  if (signal?.aborted)
687
1304
  return;
@@ -692,6 +1309,7 @@ export function App({ ctx: initialCtx }) {
692
1309
  const s = await createAgentSession(prefs, detectedNow, {
693
1310
  scope,
694
1311
  cwd: activeCtx.cwd,
1312
+ enableComputerUse: true,
695
1313
  rules: activeCtx.rules,
696
1314
  ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
697
1315
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
@@ -732,6 +1350,13 @@ export function App({ ctx: initialCtx }) {
732
1350
  // the file is shadowed instead of leaving it silently unused.
733
1351
  reserved: hostCommandNames(),
734
1352
  }));
1353
+ const mentionLoadOwner = {};
1354
+ mentionLoadOwnerRef.current = mentionLoadOwner;
1355
+ void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
1356
+ if (!appLifetime.signal.aborted &&
1357
+ mentionLoadOwnerRef.current === mentionLoadOwner)
1358
+ setMentionCandidates(files);
1359
+ });
735
1360
  setCurrentProvider(primaryProvider(prefs).id);
736
1361
  // A picker-owned, usable provider switch is an explicit recovery from a
737
1362
  // paused failed turn. Failed and superseded candidates return above and
@@ -779,7 +1404,7 @@ export function App({ ctx: initialCtx }) {
779
1404
  if (s.errorHint)
780
1405
  pushMessage('system', s.errorHint);
781
1406
  }
782
- }, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
1407
+ }, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
783
1408
  /**
784
1409
  * Sign in to a subscription without leaving namzu.
785
1410
  *
@@ -942,6 +1567,19 @@ export function App({ ctx: initialCtx }) {
942
1567
  try {
943
1568
  if (signal?.aborted)
944
1569
  return;
1570
+ // An exact shell resume owns conversation admission before provider
1571
+ // discovery or construction. A malformed/missing id must not consume
1572
+ // model work and then quietly continue in a fresh conversation.
1573
+ if (initialConversationIdRef.current) {
1574
+ try {
1575
+ await ensureSessions();
1576
+ }
1577
+ catch (error) {
1578
+ setPhase('unhealthy');
1579
+ pushMessage('system', `Could not resume ${initialConversationIdRef.current}: ${error instanceof Error ? error.message : String(error)}`);
1580
+ return;
1581
+ }
1582
+ }
945
1583
  const probe = await probeAgentSession();
946
1584
  if (signal?.aborted)
947
1585
  return;
@@ -1029,7 +1667,7 @@ export function App({ ctx: initialCtx }) {
1029
1667
  setPhase('unhealthy');
1030
1668
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
1031
1669
  }
1032
- }, [appLifetime.signal, hydrateSession, pushMessage]);
1670
+ }, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
1033
1671
  // `startOrFinishLogin` is declared above `runProbe` and calls it, so it
1034
1672
  // reads the current one through a ref rather than closing over a stale
1035
1673
  // binding or forcing the two into a declaration order that reads backwards.
@@ -1085,8 +1723,8 @@ export function App({ ctx: initialCtx }) {
1085
1723
  // is idempotent, so a repeated render reaches the same answer.
1086
1724
  const window = liveWindow({
1087
1725
  messages: finalized,
1088
- rows: process.stdout.rows,
1089
- columns: process.stdout.columns,
1726
+ rows: stdout.rows,
1727
+ columns: stdout.columns,
1090
1728
  furnitureRows: LIVE_FURNITURE_ROWS,
1091
1729
  settled: settledRef.current,
1092
1730
  raw: rawOutput,
@@ -1096,14 +1734,28 @@ export function App({ ctx: initialCtx }) {
1096
1734
  // the answer is knowable. `liveRows` is the furniture beneath the transcript
1097
1735
  // PLUS the live window above it — the window is part of the live region, and
1098
1736
  // leaving it out would have the spacer padding room that is already taken.
1099
- const spacerRows = phase === 'ready'
1737
+ const spacerCandidate = phase === 'ready' && permission === null
1100
1738
  ? bottomSpacerRows({
1101
- rows: process.stdout.rows,
1102
- columns: process.stdout.columns,
1739
+ rows: stdout.rows,
1740
+ columns: stdout.columns,
1103
1741
  transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
1104
1742
  liveRows: LIVE_FURNITURE_ROWS + window.rows,
1105
1743
  })
1106
1744
  : 0;
1745
+ const spacerLayout = {
1746
+ rows: stdout.rows,
1747
+ columns: stdout.columns,
1748
+ raw: rawOutput,
1749
+ messageCount: finalized.length,
1750
+ tail: finalized
1751
+ .slice(-MAX_LIVE_ROWS)
1752
+ .map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
1753
+ };
1754
+ const spacerRows = phase === 'ready' && permission === null && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
1755
+ ? spacerLayoutRef.current.spacerRows
1756
+ : spacerCandidate;
1757
+ spacerLayoutRef.current =
1758
+ phase === 'ready' && permission === null ? { ...spacerLayout, spacerRows } : null;
1107
1759
  // One merged vocabulary for the session: this host's own commands plus
1108
1760
  // whatever the kernel's registry reports. Built here so `/help`, the
1109
1761
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1111,6 +1763,7 @@ export function App({ ctx: initialCtx }) {
1111
1763
  // disagree the moment a capability added a command.
1112
1764
  const hostCommands = mergeHostCommands(kernelCommandDescriptors());
1113
1765
  const slashCtx = {
1766
+ cwd: ctx.cwd,
1114
1767
  builtins: hostCommands,
1115
1768
  lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
1116
1769
  // Called when `/tools` renders, not read here — the same shape, and the
@@ -1119,7 +1772,12 @@ export function App({ ctx: initialCtx }) {
1119
1772
  // From the session, not re-resolved: resolving builds a provider, and a
1120
1773
  // second one would describe a different sandbox than the run is using.
1121
1774
  sandbox: session?.sandbox ?? null,
1122
- mcp: session ? { connected: session.mcpConnected, failed: session.mcpFailed } : null,
1775
+ mcp: () => session
1776
+ ? (session.mcpStatus?.() ?? {
1777
+ connected: session.mcpConnected,
1778
+ failed: session.mcpFailed,
1779
+ })
1780
+ : null,
1123
1781
  providerSummary: session?.providerSummary ?? null,
1124
1782
  modelSummary: session?.modelSummary ?? null,
1125
1783
  reasoningEffort: {
@@ -1182,10 +1840,11 @@ export function App({ ctx: initialCtx }) {
1182
1840
  const resolve = permissionResolveRef.current;
1183
1841
  permissionResolveRef.current = null;
1184
1842
  permissionOpenedAtRef.current = null;
1843
+ setPermissionReviewOffset(0);
1185
1844
  setPermission(null);
1186
1845
  if (resolve)
1187
1846
  resolve(decision);
1188
- }, []);
1847
+ }, [setPermissionReviewOffset]);
1189
1848
  /**
1190
1849
  * Stop the running turn, and hand the screen back in a usable state.
1191
1850
  *
@@ -1210,12 +1869,14 @@ export function App({ ctx: initialCtx }) {
1210
1869
  const ac = abortRef.current;
1211
1870
  if (!ac)
1212
1871
  return false;
1213
- ac.abort();
1872
+ ac.abort(new RunCancelled('user'));
1214
1873
  const activeSessionId = scopeRef.current?.sessionId;
1215
1874
  if (activeSessionId)
1216
1875
  goalActivation.disarm(activeSessionId);
1217
1876
  wakeGoalDriver();
1218
1877
  activeTurnTokenRef.current = null;
1878
+ activeTurnInboxRef.current = null;
1879
+ setPendingSteers([]);
1219
1880
  // Dropped now so a second interrupt does not re-abort, and the queue with
1220
1881
  // it: interrupting means stop, not "run the next one".
1221
1882
  abortRef.current = null;
@@ -1301,6 +1962,7 @@ export function App({ ctx: initialCtx }) {
1301
1962
  resetTranscript();
1302
1963
  setMessages(restored);
1303
1964
  modelHistoryRef.current = msgs;
1965
+ setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
1304
1966
  const persistedOutput = latestAssistantOutput(msgs);
1305
1967
  lastCompletedOutputRef.current = persistedOutput
1306
1968
  ? { text: persistedOutput, provenance: 'persisted' }
@@ -1397,11 +2059,12 @@ export function App({ ctx: initialCtx }) {
1397
2059
  }
1398
2060
  }, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
1399
2061
  /**
1400
- * `/title`: read or set the name this conversation appears under.
2062
+ * `/rename` (and the older `/title` alias): edit or set the name this
2063
+ * conversation appears under.
1401
2064
  *
1402
- * A bare `/title` asks rather than clears. Erasing a name by pressing
1403
- * enter on a half-typed command is the kind of loss nobody notices until
1404
- * `/resume` is a list of opening messages again.
2065
+ * A bare command opens a prefilled host prompt rather than printing usage or
2066
+ * clearing. The result never goes through the model composer, queue, or prompt
2067
+ * history; it is a session-store operation with the session id captured here.
1405
2068
  */
1406
2069
  const doTitle = useCallback(async (title, clear) => {
1407
2070
  const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
@@ -1413,9 +2076,16 @@ export function App({ ctx: initialCtx }) {
1413
2076
  try {
1414
2077
  if (!clear && title === '') {
1415
2078
  const current = titleOf(sessions, scope.sessionId);
1416
- pushMessage('system', current !== undefined
1417
- ? `This conversation is named "${current}". /title <name> renames it; /title clear removes the name.`
1418
- : '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.');
2079
+ textPromptTokenRef.current += 1;
2080
+ setTextPrompt({
2081
+ token: textPromptTokenRef.current,
2082
+ kind: 'conversation-title',
2083
+ title: current === undefined ? 'Name conversation' : 'Rename conversation',
2084
+ placeholder: 'Type a name and press Enter',
2085
+ emptyNotice: 'A conversation name cannot be empty. Press Esc to cancel.',
2086
+ initialValue: current ?? '',
2087
+ sessionId: scope.sessionId,
2088
+ });
1419
2089
  return;
1420
2090
  }
1421
2091
  setTitle(sessions, scope.sessionId, clear ? '' : title);
@@ -1426,7 +2096,46 @@ export function App({ ctx: initialCtx }) {
1426
2096
  catch (err) {
1427
2097
  pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
1428
2098
  }
1429
- }, [ensureSessions, pushMessage]);
2099
+ }, [ensureSessions, pushMessage, setTextPrompt]);
2100
+ const submitTextPrompt = useCallback((value) => {
2101
+ const prompt = textPromptRef.current;
2102
+ if (!prompt)
2103
+ return;
2104
+ setTextPrompt(null);
2105
+ const scope = scopeRef.current;
2106
+ if (!scope || scope.sessionId !== prompt.sessionId) {
2107
+ pushMessage('system', prompt.kind === 'conversation-title'
2108
+ ? 'The conversation changed while its name editor was open. Nothing was renamed; open /rename again.'
2109
+ : 'The conversation changed while its export filename editor was open. Nothing was exported; open /export again.');
2110
+ return;
2111
+ }
2112
+ if (prompt.kind === 'export-file') {
2113
+ runConversationExport({ kind: 'file', path: value });
2114
+ return;
2115
+ }
2116
+ const sessions = sessionsRef.current;
2117
+ if (!sessions) {
2118
+ pushMessage('system', 'Conversation history is unavailable in this folder.');
2119
+ return;
2120
+ }
2121
+ try {
2122
+ setTitle(sessions, prompt.sessionId, value);
2123
+ pushMessage('system', `Named "${value.trim()}".`);
2124
+ }
2125
+ catch (error) {
2126
+ pushMessage('system', `Could not save the name: ${error instanceof Error ? error.message : String(error)}`);
2127
+ }
2128
+ }, [pushMessage, runConversationExport, setTextPrompt]);
2129
+ const cancelTextPrompt = useCallback(() => {
2130
+ const prompt = textPromptRef.current;
2131
+ // TextPrompt and App both receive the same Ink input event. Keep the
2132
+ // synchronous owner until every listener has seen this Esc/Ctrl+C, or App
2133
+ // can read the same key as an interrupt/exit after the child clears the ref.
2134
+ queueMicrotask(() => {
2135
+ if (textPromptRef.current === prompt)
2136
+ setTextPrompt(null);
2137
+ });
2138
+ }, [setTextPrompt]);
1430
2139
  /**
1431
2140
  * `/fork`: continue in a copy, leaving this conversation where it is.
1432
2141
  *
@@ -1582,13 +2291,23 @@ export function App({ ctx: initialCtx }) {
1582
2291
  ]);
1583
2292
  // Bridge passed into session.send(): the agent calls this before a
1584
2293
  // non-read-only tool batch; it parks until the user presses y/n/a.
1585
- const onPermission = useCallback((req) => new Promise((resolve) => {
1586
- permissionResolveRef.current = resolve;
1587
- permissionOpenedAtRef.current = Date.now();
1588
- setPermission(req);
1589
- setState('awaiting-permission');
1590
- sendTerminalNotification({ kind: 'approval-required' });
1591
- }), [sendTerminalNotification]);
2294
+ const onPermission = useCallback((req) => {
2295
+ const review = buildPermissionReview(req.toolCalls);
2296
+ if (!review.ok) {
2297
+ return Promise.resolve({
2298
+ kind: 'reject',
2299
+ feedback: permissionReviewRefusal(review.reason),
2300
+ });
2301
+ }
2302
+ return new Promise((resolve) => {
2303
+ permissionResolveRef.current = resolve;
2304
+ permissionOpenedAtRef.current = Date.now();
2305
+ setPermissionReviewOffset(0);
2306
+ setPermission({ ...req, review: review.text });
2307
+ setState('awaiting-permission');
2308
+ sendTerminalNotification({ kind: 'approval-required' });
2309
+ });
2310
+ }, [sendTerminalNotification, setPermissionReviewOffset]);
1592
2311
  // Render one agent event onto the transcript. Shared by the local turn loop
1593
2312
  // and the daemon-attach poller, so both paths produce identical output.
1594
2313
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -1645,6 +2364,28 @@ export function App({ ctx: initialCtx }) {
1645
2364
  setActiveTools(activeToolsRef.current);
1646
2365
  break;
1647
2366
  }
2367
+ case 'tool-progress': {
2368
+ const running = activeToolsRef.current;
2369
+ const index = running.findIndex((tool) => tool.id === event.toolUseId);
2370
+ // A terminal event may already have removed this call. Never recreate
2371
+ // a live row from late diagnostic state.
2372
+ if (index < 0)
2373
+ break;
2374
+ const current = running[index];
2375
+ const { fraction: _fraction, ...withoutFraction } = current;
2376
+ const updated = {
2377
+ ...withoutFraction,
2378
+ progress: event.message,
2379
+ ...(event.fraction !== undefined ? { fraction: event.fraction } : {}),
2380
+ };
2381
+ activeToolsRef.current = [
2382
+ ...running.slice(0, index),
2383
+ updated,
2384
+ ...running.slice(index + 1),
2385
+ ];
2386
+ setActiveTools(activeToolsRef.current);
2387
+ break;
2388
+ }
1648
2389
  case 'tool-end': {
1649
2390
  const running = activeToolsRef.current;
1650
2391
  // Match strictly by toolUseId. Never fall back to "the first
@@ -1794,16 +2535,11 @@ export function App({ ctx: initialCtx }) {
1794
2535
  : undefined);
1795
2536
  const priorForSdk = [...historyBeforeTurn, userMessage];
1796
2537
  const runId = generateRunId();
1797
- const metaParts = [];
1798
- if (attached.length > 0)
1799
- metaParts.push(`${attached.length} file${attached.length > 1 ? 's' : ''} attached`);
1800
- if (attachments && attachments.length > 0)
1801
- metaParts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
1802
2538
  if (goalRound) {
1803
2539
  pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
1804
2540
  }
1805
2541
  else {
1806
- pushMessage('user', text, false, undefined, undefined, undefined, metaParts.length > 0 ? metaParts.join(' · ') : undefined);
2542
+ pushMessage('user', text, false, undefined, undefined, undefined, humanPromptMeta(attached.length, attachments));
1807
2543
  }
1808
2544
  // The model interleaves text → tool → text across iterations; `applyEvent`
1809
2545
  // renders each one in order.
@@ -1845,6 +2581,51 @@ export function App({ ctx: initialCtx }) {
1845
2581
  const turnGeneration = conversationGenRef.current;
1846
2582
  unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
1847
2583
  const stillHere = () => conversationGenRef.current === turnGeneration;
2584
+ let inboxOpen = true;
2585
+ const inboxEntries = [];
2586
+ const inbox = {
2587
+ accept(input) {
2588
+ if (!inboxOpen ||
2589
+ ac.signal.aborted ||
2590
+ !stillHere() ||
2591
+ activeTurnTokenRef.current !== turnToken ||
2592
+ activeTurnInboxRef.current !== inbox)
2593
+ return false;
2594
+ inboxEntries.push(input);
2595
+ setPendingSteers([...inboxEntries]);
2596
+ return true;
2597
+ },
2598
+ drain() {
2599
+ if (!inboxOpen ||
2600
+ ac.signal.aborted ||
2601
+ activeTurnTokenRef.current !== turnToken ||
2602
+ activeTurnInboxRef.current !== inbox ||
2603
+ inboxEntries.length === 0)
2604
+ return [];
2605
+ const delivered = inboxEntries.splice(0, inboxEntries.length);
2606
+ // This callback runs at the kernel's provider-valid boundary: every
2607
+ // delta from the prior answer has already crossed the event stream.
2608
+ // Commit the user rows here, not at keypress time, so the transcript
2609
+ // cannot place a steer inside an assistant message that is still live.
2610
+ if (stillHere()) {
2611
+ flushStream(st);
2612
+ if (st.assistantId) {
2613
+ finalizeMessage(st.assistantId);
2614
+ st.assistantId = null;
2615
+ }
2616
+ for (const input of delivered) {
2617
+ pushMessage('user', input.prompt.text, false, undefined, undefined, undefined, humanPromptMeta(input.attachedFiles, input.prompt.attachments, true));
2618
+ }
2619
+ setPendingSteers([]);
2620
+ }
2621
+ return delivered.map((input) => input.message);
2622
+ },
2623
+ close() {
2624
+ inboxOpen = false;
2625
+ return inboxEntries.splice(0, inboxEntries.length);
2626
+ },
2627
+ };
2628
+ activeTurnInboxRef.current = inbox;
1848
2629
  const turnPermissionMode = permissionModeRef.current;
1849
2630
  const turnReasoningEffort = reasoningEffortRef.current;
1850
2631
  // Always carry the guarded callback. `auto` and `strict` decide before
@@ -1908,6 +2689,7 @@ export function App({ ctx: initialCtx }) {
1908
2689
  ...(goalRound ? { goalRound } : {}),
1909
2690
  // The mode above decides whether this callback is consulted.
1910
2691
  onPermission: askPermission,
2692
+ inboundMessages: () => inbox.drain(),
1911
2693
  extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
1912
2694
  onConversationMessages: (messages) => {
1913
2695
  // State-only: opaque reasoning/signatures must reach the next
@@ -1960,6 +2742,12 @@ export function App({ ctx: initialCtx }) {
1960
2742
  }
1961
2743
  }
1962
2744
  finally {
2745
+ const undeliveredLiveInput = inbox.close();
2746
+ const ownsInbox = activeTurnInboxRef.current === inbox;
2747
+ if (ownsInbox) {
2748
+ activeTurnInboxRef.current = null;
2749
+ setPendingSteers([]);
2750
+ }
1963
2751
  // Prefer the kernel's settled conversation projection: it retains opaque
1964
2752
  // reasoning, citations and complete tool turns that the visible delta
1965
2753
  // stream cannot reconstruct. A fake/legacy session that does not publish
@@ -1984,6 +2772,9 @@ export function App({ ctx: initialCtx }) {
1984
2772
  // clear the state of whatever has started since.
1985
2773
  if (stillHere()) {
1986
2774
  const ownsTurn = activeTurnTokenRef.current === turnToken;
2775
+ if (ownsInbox && undeliveredLiveInput.length > 0) {
2776
+ replaceQueued(mergeUndeliveredLiveInput(queuedRef.current, undeliveredLiveInput));
2777
+ }
1987
2778
  const shouldPauseQueued = ownsTurn &&
1988
2779
  abnormalTerminal?.token === turnToken &&
1989
2780
  abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
@@ -2087,12 +2878,13 @@ export function App({ ctx: initialCtx }) {
2087
2878
  goalActivation,
2088
2879
  onPermission,
2089
2880
  pushMessage,
2881
+ replaceQueued,
2090
2882
  sendTerminalNotification,
2091
2883
  session,
2092
2884
  setQueuePause,
2093
2885
  wakeGoalDriver,
2094
2886
  ]);
2095
- const handleSubmit = useCallback((value, attachments) => {
2887
+ const handleSubmit = useCallback((value, attachments, mode = 'submit') => {
2096
2888
  if (goalCommandInFlightRef.current) {
2097
2889
  pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
2098
2890
  return;
@@ -2106,7 +2898,9 @@ export function App({ ctx: initialCtx }) {
2106
2898
  ? 'forked'
2107
2899
  : conversationMutationRef.current === 'edit'
2108
2900
  ? 'branched for prompt editing'
2109
- : 'moved to a fresh conversation';
2901
+ : conversationMutationRef.current === 'archive'
2902
+ ? 'archived'
2903
+ : 'moved to a fresh conversation';
2110
2904
  pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
2111
2905
  return;
2112
2906
  }
@@ -2122,15 +2916,76 @@ export function App({ ctx: initialCtx }) {
2122
2916
  case 'message':
2123
2917
  pushMessage(slash.role, slash.content);
2124
2918
  return;
2919
+ case 'command-picker': {
2920
+ if (slash.commands.length === 0) {
2921
+ pushMessage('system', 'No slash commands are available in this session.');
2922
+ return;
2923
+ }
2924
+ setSelectedChoice(0);
2925
+ setChoicePicker({
2926
+ kind: 'command',
2927
+ title: 'Choose a command',
2928
+ notice: 'Enter runs the selected command; Esc returns to the composer.',
2929
+ values: slash.commands,
2930
+ options: slash.commands.map((command) => ({
2931
+ label: `/${command.name}`,
2932
+ description: command.problem
2933
+ ? `Unavailable: ${command.problem}`
2934
+ : command.description,
2935
+ })),
2936
+ windowSize: suggestionWindowSize(stdout.rows),
2937
+ });
2938
+ return;
2939
+ }
2125
2940
  case 'clear-screen':
2126
2941
  setMessages([]);
2127
2942
  resetTranscript();
2128
2943
  return;
2944
+ case 'composer-draft':
2945
+ composerDraftTokenRef.current += 1;
2946
+ setComposerDraft({
2947
+ token: composerDraftTokenRef.current,
2948
+ text: slash.text,
2949
+ });
2950
+ return;
2129
2951
  case 'new-conversation':
2130
2952
  void startFreshConversation(slash.clearScreen);
2131
2953
  return;
2954
+ case 'archive-picker': {
2955
+ if (!sessionsRef.current || !scopeRef.current?.sessionId) {
2956
+ pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
2957
+ return;
2958
+ }
2959
+ if (state !== 'idle' ||
2960
+ abortRef.current !== null ||
2961
+ hasUnsettledTurn() ||
2962
+ queuedRef.current.length > 0 ||
2963
+ compactingRef.current ||
2964
+ exportingRef.current) {
2965
+ pushMessage('system', 'A turn, queued prompt, compaction, or export is still running or settling. Archive waits for a stable durable boundary.');
2966
+ return;
2967
+ }
2968
+ setSelectedChoice(0);
2969
+ setChoicePicker({
2970
+ kind: 'archive-conversation',
2971
+ title: 'Archive this conversation?',
2972
+ notice: 'History remains on disk for inspection, but the conversation becomes read-only and disappears from /resume.',
2973
+ values: ['cancel', 'archive'],
2974
+ options: [
2975
+ {
2976
+ label: 'No, keep conversation',
2977
+ description: 'Return without changing durable history',
2978
+ },
2979
+ {
2980
+ label: 'Yes, archive and exit',
2981
+ description: 'Make this conversation read-only and leave Namzu',
2982
+ },
2983
+ ],
2984
+ });
2985
+ return;
2986
+ }
2132
2987
  case 'exit':
2133
- exit();
2988
+ exitWithSummary();
2134
2989
  return;
2135
2990
  case 'repick':
2136
2991
  // Opened as a choice, not as a repair. A launch-time refusal
@@ -2215,16 +3070,35 @@ export function App({ ctx: initialCtx }) {
2215
3070
  void startOrFinishLogin(slash.pasted);
2216
3071
  return;
2217
3072
  case 'logout': {
2218
- const path = credentialsPath();
2219
- const had = readStoredSubscriptionCredential() !== null || readStoredCodexCredential() !== null;
2220
- try {
2221
- clearAllStoredCredentials();
3073
+ if (slash.target) {
3074
+ removeStoredCredential(slash.target);
3075
+ return;
2222
3076
  }
2223
- catch (err) {
2224
- pushMessage('system', `Could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
3077
+ const choices = [
3078
+ ...(readStoredSubscriptionCredential() ? ['anthropic'] : []),
3079
+ ...(readStoredCodexCredential() ? ['codex'] : []),
3080
+ ];
3081
+ if (choices.length === 0) {
3082
+ removeStoredCredential('all');
3083
+ return;
3084
+ }
3085
+ if (choices.length === 1) {
3086
+ removeStoredCredential(choices[0]);
2225
3087
  return;
2226
3088
  }
2227
- pushMessage('system', describeLogout(path, had));
3089
+ setSelectedChoice(0);
3090
+ setChoicePicker({
3091
+ kind: 'credential-logout',
3092
+ title: 'Choose a stored subscription to remove',
3093
+ notice: 'Only credentials created by Namzu are listed. Device sessions owned by other tools are left alone.',
3094
+ values: choices,
3095
+ options: choices.map((choice) => ({
3096
+ label: choice === 'anthropic' ? 'Claude' : 'Codex',
3097
+ description: choice === 'anthropic'
3098
+ ? 'Remove only Namzu’s Claude subscription.'
3099
+ : 'Remove only Namzu’s Codex subscription.',
3100
+ })),
3101
+ });
2228
3102
  return;
2229
3103
  }
2230
3104
  case 'remember':
@@ -2262,23 +3136,30 @@ export function App({ ctx: initialCtx }) {
2262
3136
  pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
2263
3137
  return;
2264
3138
  }
2265
- case 'load-skill': {
2266
- const info = discoverSkills({ cwd: ctx.cwd }).find((s) => s.name === slash.name);
2267
- if (!info) {
2268
- pushMessage('system', `No skill named "${slash.name}". See /skills.`);
3139
+ case 'skill-picker': {
3140
+ const skills = discoverSkills({ cwd: ctx.cwd });
3141
+ if (skills.length === 0) {
3142
+ pushMessage('system', 'No skills found. Add one at ~/.namzu/skills/<name>/SKILL.md or ./skills/<name>/SKILL.md.');
2269
3143
  return;
2270
3144
  }
2271
- try {
2272
- const body = loadSkillBody(info);
2273
- setActiveSkills((prev) => [
2274
- ...prev.filter((s) => s.name !== info.name),
2275
- { name: info.name, body },
2276
- ]);
2277
- pushMessage('system', `Activated skill: ${info.name}`);
2278
- }
2279
- catch (err) {
2280
- pushMessage('system', `Could not load skill "${slash.name}": ${err instanceof Error ? err.message : String(err)}`);
2281
- }
3145
+ const activeNames = new Set(activeSkills.map((skill) => skill.name));
3146
+ setSelectedChoice(0);
3147
+ setChoicePicker({
3148
+ kind: 'skill',
3149
+ title: 'Activate a skill',
3150
+ values: skills.map((skill) => skill.name),
3151
+ options: skills.map((skill) => ({
3152
+ label: skill.name,
3153
+ description: skill.problem
3154
+ ? `Unavailable: ${skill.problem}`
3155
+ : `${skill.description} · ${skill.source}`,
3156
+ current: activeNames.has(skill.name),
3157
+ })),
3158
+ });
3159
+ return;
3160
+ }
3161
+ case 'load-skill': {
3162
+ activateSkill(slash.name);
2282
3163
  return;
2283
3164
  }
2284
3165
  case 'resume':
@@ -2424,67 +3305,76 @@ export function App({ ctx: initialCtx }) {
2424
3305
  })();
2425
3306
  return;
2426
3307
  }
2427
- case 'feedback': {
3308
+ case 'feedback-picker': {
2428
3309
  const target = lastAssistantMessage.current;
2429
- // Written under the same `<cwd>/.namzu` root the runs live
2430
- // in, so a rating and the transcript it judges are one
2431
- // directory apart and travel together.
2432
- if (!target) {
3310
+ if (!target || target.messageId !== slash.messageId) {
2433
3311
  pushMessage('system', 'Nothing to rate yet.');
2434
3312
  return;
2435
3313
  }
2436
- const store = new DiskMessageFeedbackStore({
2437
- rootDir: join(ctx.cwd, '.namzu', 'feedback'),
2438
- runsDir: join(ctx.cwd, '.namzu', 'runs'),
3314
+ const values = ['good', 'bad'];
3315
+ setSelectedChoice(0);
3316
+ setChoicePicker({
3317
+ kind: 'feedback-rating',
3318
+ title: 'Rate the latest answer',
3319
+ runId: target.runId,
3320
+ messageId: target.messageId,
3321
+ values,
3322
+ options: [
3323
+ {
3324
+ label: 'good',
3325
+ description: 'The answer was useful and correct.',
3326
+ },
3327
+ { label: 'bad', description: 'The answer needs improvement.' },
3328
+ ],
2439
3329
  });
2440
- const runId = target.runId;
2441
- const messageId = slash.messageId;
2442
- // Fire-and-forget: this switch is synchronous, and the
2443
- // transcript reports the outcome either way rather than
2444
- // blocking a keystroke on a disk write.
2445
- void (async () => {
2446
- try {
2447
- // A first rating expects nothing; a second replaces the
2448
- // first, which is what a rater changing their mind does.
2449
- // Read-then-write rather than blind overwrite, so two
2450
- // raters on one message still collide loudly.
2451
- const current = (await store.listMessageFeedback({ runId })).find((r) => r.messageId === messageId);
2452
- const record = await store.putMessageFeedback({
2453
- runId,
2454
- messageId,
2455
- rating: slash.rating,
2456
- ...(slash.note ? { note: slash.note } : {}),
2457
- expectedVersion: current?.ownerVersion ?? 0,
2458
- });
2459
- pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
2460
- }
2461
- catch (err) {
2462
- pushMessage('system', `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`);
2463
- }
2464
- })();
3330
+ return;
3331
+ }
3332
+ case 'feedback': {
3333
+ const target = lastAssistantMessage.current;
3334
+ if (!target || target.messageId !== slash.messageId) {
3335
+ pushMessage('system', 'Nothing to rate yet.');
3336
+ return;
3337
+ }
3338
+ recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
2465
3339
  return;
2466
3340
  }
2467
3341
  case 'review': {
2468
- void (async () => {
2469
- const diff = await workspaceDiff(ctx.cwd);
2470
- if (diff === null) {
2471
- pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
2472
- return;
2473
- }
2474
- if (diff.stat.length === 0 && diff.untracked.length === 0) {
2475
- // Refused rather than sent. A review request over an
2476
- // unchanged tree burns a turn and comes back with a
2477
- // review of nothing, which reads exactly like a review
2478
- // of something.
2479
- pushMessage('system', 'Nothing to review — the working tree is clean.');
2480
- return;
2481
- }
2482
- // The same FIFO a typed message enters. A review composed
2483
- // while a turn is in flight must not jump the queue, and
2484
- // must not be dropped either.
2485
- const text = reviewPrompt(diff.stat, diff.untracked);
2486
- enqueueQueued({ kind: 'human', text });
2487
- })();
3342
+ if (slash.instructions) {
3343
+ // Custom instructions are already operator-authored model input.
3344
+ // They take the ordinary FIFO below rather than a second send path.
3345
+ outgoing = slash.instructions;
3346
+ break;
3347
+ }
3348
+ const values = [
3349
+ 'base-branch',
3350
+ 'uncommitted',
3351
+ 'commit',
3352
+ 'custom',
3353
+ ];
3354
+ setSelectedChoice(0);
3355
+ setChoicePicker({
3356
+ kind: 'review-preset',
3357
+ title: 'Select a review preset',
3358
+ values,
3359
+ options: [
3360
+ {
3361
+ label: 'Base branch',
3362
+ description: 'Review the current work as a branch comparison.',
3363
+ },
3364
+ {
3365
+ label: 'Uncommitted',
3366
+ description: 'Review staged, unstaged, and untracked changes.',
3367
+ },
3368
+ {
3369
+ label: 'Commit',
3370
+ description: 'Review one recent commit.',
3371
+ },
3372
+ {
3373
+ label: 'Custom',
3374
+ description: 'Write exact review instructions in the composer.',
3375
+ },
3376
+ ],
3377
+ });
2488
3378
  return;
2489
3379
  }
2490
3380
  case 'diff': {
@@ -2609,45 +3499,30 @@ export function App({ ctx: initialCtx }) {
2609
3499
  : 'Raw output mode off — rich transcript rendering restored.');
2610
3500
  return;
2611
3501
  }
2612
- case 'export': {
2613
- const sessions = sessionsRef.current;
2614
- const destination = scopeRef.current?.sessionId;
2615
- if (!sessions || !destination) {
2616
- pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
3502
+ case 'export-picker': {
3503
+ if (!stableExportSource())
2617
3504
  return;
2618
- }
2619
- if (abortRef.current ||
2620
- state !== 'idle' ||
2621
- hasUnsettledTurn() ||
2622
- compactingRef.current ||
2623
- queuedRef.current.length > 0) {
2624
- 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.');
2625
- return;
2626
- }
2627
- exportingRef.current = true;
2628
- setState('thinking');
2629
- const generation = conversationGenRef.current;
2630
- const path = slash.path ?? `namzu-conversation-${destination}.md`;
2631
- void (async () => {
2632
- try {
2633
- await persistenceTailRef.current;
2634
- if (conversationGenRef.current !== generation ||
2635
- scopeRef.current?.sessionId !== destination) {
2636
- throw new Error('The active conversation changed before the export reached its durable boundary.');
2637
- }
2638
- const projected = await conversationMarkdown(sessions, destination);
2639
- const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
2640
- pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
2641
- }
2642
- catch (err) {
2643
- pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
2644
- }
2645
- finally {
2646
- exportingRef.current = false;
2647
- if (conversationGenRef.current === generation)
2648
- setState('idle');
2649
- }
2650
- })();
3505
+ setSelectedChoice(0);
3506
+ setChoicePicker({
3507
+ kind: 'export-destination',
3508
+ title: 'Export conversation',
3509
+ notice: 'Save the complete verified conversation as Markdown.',
3510
+ values: ['clipboard', 'file'],
3511
+ options: [
3512
+ {
3513
+ label: 'Copy to clipboard',
3514
+ description: 'Send the verified Markdown transcript to the terminal clipboard',
3515
+ },
3516
+ {
3517
+ label: 'Save to file',
3518
+ description: 'Choose a Markdown filename; existing files are never overwritten',
3519
+ },
3520
+ ],
3521
+ });
3522
+ return;
3523
+ }
3524
+ case 'export': {
3525
+ runConversationExport({ kind: 'file', path: slash.path });
2651
3526
  return;
2652
3527
  }
2653
3528
  default: {
@@ -2671,11 +3546,25 @@ export function App({ ctx: initialCtx }) {
2671
3546
  // Keep the attachment array beside its text. Reconstructing a prompt from
2672
3547
  // the transcript later cannot recover bytes whose composer chip is gone.
2673
3548
  advanceQueueContinuation();
2674
- enqueueQueued({
3549
+ const prompt = {
2675
3550
  kind: 'human',
2676
3551
  text: outgoing,
2677
3552
  ...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
2678
- });
3553
+ };
3554
+ if (mode === 'submit') {
3555
+ const inbox = activeTurnInboxRef.current;
3556
+ if (inbox) {
3557
+ const expanded = expandFileMentions(outgoing, ctx.cwd);
3558
+ if (inbox.accept({
3559
+ prompt,
3560
+ message: createUserMessage(expanded.sendText, prompt.attachments),
3561
+ attachedFiles: expanded.attached.length,
3562
+ queueBoundary: queuedRef.current.length,
3563
+ }))
3564
+ return;
3565
+ }
3566
+ }
3567
+ enqueueQueued(prompt);
2679
3568
  }, [
2680
3569
  activeSkills,
2681
3570
  advanceQueueContinuation,
@@ -2684,19 +3573,28 @@ export function App({ ctx: initialCtx }) {
2684
3573
  ctx.cwd,
2685
3574
  doResume,
2686
3575
  enqueueQueued,
2687
- exit,
3576
+ exitWithSummary,
2688
3577
  hasUnsettledTurn,
2689
3578
  nextId,
2690
3579
  pushMessage,
2691
3580
  rawOutput,
3581
+ removeStoredCredential,
2692
3582
  resetTranscript,
3583
+ runConversationExport,
2693
3584
  setChoicePicker,
2694
3585
  setCopyPicker,
2695
3586
  setReasoningEffort,
2696
3587
  slashCtx,
3588
+ stableExportSource,
2697
3589
  startFreshConversation,
2698
3590
  state,
3591
+ stdout.rows,
2699
3592
  ]);
3593
+ // `applyChoiceSelection` is declared before `handleSubmit` because the
3594
+ // picker is also used by review/export flows. Keep only this dispatch hop in
3595
+ // a ref so selecting a help row re-enters the one ordinary slash-command
3596
+ // path instead of growing a second command executor.
3597
+ commandPickerSubmitRef.current = handleSubmit;
2700
3598
  // Keep the footer on the same durable goal record the driver mutates. A ref
2701
3599
  // alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
2702
3600
  // generation/session checks stop a late disk read labelling a resumed chat.
@@ -2749,6 +3647,7 @@ export function App({ ctx: initialCtx }) {
2749
3647
  conversationMutationRef.current ||
2750
3648
  exportingRef.current ||
2751
3649
  compactingRef.current ||
3650
+ textPromptRef.current ||
2752
3651
  choicePickerRef.current ||
2753
3652
  copyPickerRef.current ||
2754
3653
  goalCommandInFlightRef.current) {
@@ -2775,6 +3674,7 @@ export function App({ ctx: initialCtx }) {
2775
3674
  conversationMutationRef.current ||
2776
3675
  exportingRef.current ||
2777
3676
  compactingRef.current ||
3677
+ textPromptRef.current ||
2778
3678
  choicePickerRef.current ||
2779
3679
  copyPickerRef.current ||
2780
3680
  goalCommandInFlightRef.current ||
@@ -2834,6 +3734,7 @@ export function App({ ctx: initialCtx }) {
2834
3734
  conversationMutation,
2835
3735
  session,
2836
3736
  state,
3737
+ textPrompt,
2837
3738
  choicePicker,
2838
3739
  copyPicker,
2839
3740
  wakeGoalDriver,
@@ -2850,6 +3751,7 @@ export function App({ ctx: initialCtx }) {
2850
3751
  conversationMutationRef.current ||
2851
3752
  exportingRef.current ||
2852
3753
  compactingRef.current ||
3754
+ textPromptRef.current ||
2853
3755
  choicePickerRef.current ||
2854
3756
  copyPickerRef.current ||
2855
3757
  goalCommandInFlightRef.current)
@@ -2866,6 +3768,7 @@ export function App({ ctx: initialCtx }) {
2866
3768
  phase,
2867
3769
  queued,
2868
3770
  queuePause,
3771
+ textPrompt,
2869
3772
  choicePicker,
2870
3773
  copyPicker,
2871
3774
  dequeueQueued,
@@ -2989,8 +3892,8 @@ export function App({ ctx: initialCtx }) {
2989
3892
  setPhase('ready');
2990
3893
  return;
2991
3894
  }
2992
- exit();
2993
- }, [session, exit]);
3895
+ exitWithSummary();
3896
+ }, [session, exitWithSummary]);
2994
3897
  useInput((input, key) => {
2995
3898
  // The provider picker owns its own keyboard — arrows, digits, enter,
2996
3899
  // esc and the typed-key screen are all its business. Ctrl+C is not: it
@@ -3006,7 +3909,7 @@ export function App({ ctx: initialCtx }) {
3006
3909
  // and the second exits", with nothing on screen naming either.
3007
3910
  if (phase === 'picker') {
3008
3911
  if (key.ctrl && input === 'c')
3009
- exit();
3912
+ exitWithSummary();
3010
3913
  return;
3011
3914
  }
3012
3915
  // Previous-prompt picker owns the keyboard. Esc keeps stepping toward
@@ -3019,16 +3922,26 @@ export function App({ ctx: initialCtx }) {
3019
3922
  setPhase('ready');
3020
3923
  return;
3021
3924
  }
3925
+ if (key.home || key.end || key.pageUp || key.pageDown) {
3926
+ setSelectedEdit((index) => moveSelection(index, editList.length, key.home
3927
+ ? 'first'
3928
+ : key.end
3929
+ ? 'last'
3930
+ : key.pageUp
3931
+ ? 'previous-page'
3932
+ : 'next-page'));
3933
+ return;
3934
+ }
3022
3935
  if (key.escape || key.leftArrow || key.upArrow) {
3023
- setSelectedEdit((index) => Math.max(0, index - 1));
3936
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
3024
3937
  return;
3025
3938
  }
3026
3939
  if (key.rightArrow || key.downArrow) {
3027
- setSelectedEdit((index) => Math.min(editList.length - 1, index + 1));
3940
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
3028
3941
  return;
3029
3942
  }
3030
3943
  if (key.return) {
3031
- const target = editList[selectedEdit];
3944
+ const target = editList[selectedEditRef.current];
3032
3945
  if (target)
3033
3946
  void confirmPromptEdit(target);
3034
3947
  }
@@ -3042,12 +3955,21 @@ export function App({ ctx: initialCtx }) {
3042
3955
  // replaced regardless.
3043
3956
  if (resumeCommittedRef.current)
3044
3957
  return;
3045
- if (key.upArrow)
3046
- setSelectedResume((i) => Math.max(0, i - 1));
3958
+ if (key.home || key.end || key.pageUp || key.pageDown) {
3959
+ setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
3960
+ ? 'first'
3961
+ : key.end
3962
+ ? 'last'
3963
+ : key.pageUp
3964
+ ? 'previous-page'
3965
+ : 'next-page'));
3966
+ }
3967
+ else if (key.upArrow)
3968
+ setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
3047
3969
  else if (key.downArrow)
3048
- setSelectedResume((i) => Math.min(resumeList.length - 1, i + 1));
3970
+ setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
3049
3971
  else if (key.return) {
3050
- const conv = resumeList[selectedResume];
3972
+ const conv = resumeList[selectedResumeRef.current];
3051
3973
  if (conv)
3052
3974
  void resumeConversation(conv);
3053
3975
  }
@@ -3064,7 +3986,7 @@ export function App({ ctx: initialCtx }) {
3064
3986
  // and making the escape hatch hesitate on the program's first screen
3065
3987
  // would read as a hang.
3066
3988
  if (ch === 'n' || key.escape || (key.ctrl && input === 'c')) {
3067
- exit();
3989
+ exitWithSummary();
3068
3990
  return;
3069
3991
  }
3070
3992
  // Enter is deliberately NOT trust, for the reason it is not approval
@@ -3084,12 +4006,31 @@ export function App({ ctx: initialCtx }) {
3084
4006
  }
3085
4007
  // A pending permission prompt owns the keyboard: y/n/a decide it.
3086
4008
  if (permissionResolveRef.current) {
4009
+ if (permission &&
4010
+ (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
4011
+ const count = permissionReviewRows(permission.review, stdout.columns).length;
4012
+ const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
4013
+ const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
4014
+ const next = key.home
4015
+ ? 0
4016
+ : key.end
4017
+ ? maxOffset
4018
+ : key.pageUp
4019
+ ? Math.max(0, current - PERMISSION_REVIEW_PAGE_ROWS)
4020
+ : key.pageDown
4021
+ ? Math.min(maxOffset, current + PERMISSION_REVIEW_PAGE_ROWS)
4022
+ : key.upArrow
4023
+ ? Math.max(0, current - 1)
4024
+ : Math.min(maxOffset, current + 1);
4025
+ setPermissionReviewOffset(next);
4026
+ return;
4027
+ }
3087
4028
  const ch = input.toLowerCase();
3088
4029
  // Refusing is never deferred or gated — it is the direction a
3089
4030
  // mistake is recoverable in, so it answers on the first press.
3090
4031
  if (key.ctrl && input === 'c') {
3091
4032
  resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
3092
- abortRef.current?.abort();
4033
+ abortRef.current?.abort(new RunCancelled('user'));
3093
4034
  return;
3094
4035
  }
3095
4036
  if (ch === 'n' || key.escape) {
@@ -3110,25 +4051,49 @@ export function App({ ctx: initialCtx }) {
3110
4051
  resolvePermission({ kind: 'approve-all' });
3111
4052
  return;
3112
4053
  }
4054
+ // A host-owned text prompt has its own input hook. This synchronous ref
4055
+ // claims the interval before React paints it too, so App cannot read a
4056
+ // key repeat as an interrupt, exit ladder, or finite-menu choice.
4057
+ if (textPromptRef.current)
4058
+ return;
3113
4059
  // Finite slash-command choices own the keyboard below a live permission
3114
4060
  // request and above the ordinary composer. Their values are a captured
3115
4061
  // menu, so a provider/session change cannot silently reinterpret Enter.
3116
4062
  if (choicePickerRef.current) {
3117
- const options = choicePickerRef.current.options;
4063
+ const picker = choicePickerRef.current;
4064
+ // Composer publishes a chooser while handling Return. App receives the
4065
+ // same input dispatch (and key repeat can arrive before the commit), so
4066
+ // the synchronous ref is an ownership fence but not yet an actionable
4067
+ // menu. Only the exact object React committed may consume a choice.
4068
+ if (choicePickerCommittedRef.current !== picker)
4069
+ return;
4070
+ const options = picker.options;
3118
4071
  if (key.escape || (key.ctrl && input === 'c')) {
4072
+ reviewChoiceInFlightRef.current = null;
3119
4073
  setChoicePicker(null);
3120
4074
  return;
3121
4075
  }
4076
+ if (key.home || key.end || key.pageUp || key.pageDown) {
4077
+ const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
4078
+ setSelectedChoice((index) => moveSelection(index, options.length, key.home
4079
+ ? 'first'
4080
+ : key.end
4081
+ ? 'last'
4082
+ : key.pageUp
4083
+ ? 'previous-page'
4084
+ : 'next-page', pageSize));
4085
+ return;
4086
+ }
3122
4087
  if (key.upArrow) {
3123
- setSelectedChoice((index) => Math.max(0, index - 1));
4088
+ setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
3124
4089
  return;
3125
4090
  }
3126
4091
  if (key.downArrow) {
3127
- setSelectedChoice((index) => Math.min(options.length - 1, index + 1));
4092
+ setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
3128
4093
  return;
3129
4094
  }
3130
4095
  if (key.return) {
3131
- applyChoiceSelection(selectedChoice);
4096
+ applyChoiceSelection(selectedChoiceRef.current);
3132
4097
  return;
3133
4098
  }
3134
4099
  if (/^[1-9]$/.test(input)) {
@@ -3147,16 +4112,26 @@ export function App({ ctx: initialCtx }) {
3147
4112
  setCopyPicker(null);
3148
4113
  return;
3149
4114
  }
4115
+ if (key.home || key.end || key.pageUp || key.pageDown) {
4116
+ setSelectedCopy((index) => moveSelection(index, targets.length, key.home
4117
+ ? 'first'
4118
+ : key.end
4119
+ ? 'last'
4120
+ : key.pageUp
4121
+ ? 'previous-page'
4122
+ : 'next-page'));
4123
+ return;
4124
+ }
3150
4125
  if (key.upArrow) {
3151
- setSelectedCopy((index) => Math.max(0, index - 1));
4126
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
3152
4127
  return;
3153
4128
  }
3154
4129
  if (key.downArrow) {
3155
- setSelectedCopy((index) => Math.min(targets.length - 1, index + 1));
4130
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
3156
4131
  return;
3157
4132
  }
3158
4133
  if (key.return) {
3159
- sendCopyRequest(selectedCopy);
4134
+ sendCopyRequest(selectedCopyRef.current);
3160
4135
  return;
3161
4136
  }
3162
4137
  if (/^[1-9]$/.test(input)) {
@@ -3166,6 +4141,19 @@ export function App({ ctx: initialCtx }) {
3166
4141
  }
3167
4142
  return;
3168
4143
  }
4144
+ // Terminal-native display clear. It deliberately shares `/clear-screen`'s
4145
+ // view-only boundary: model history, durable history and the active
4146
+ // conversation remain untouched. A live turn is refused instead of
4147
+ // erasing the only visible account of work that is still changing.
4148
+ if (key.ctrl && input === 'l') {
4149
+ if (state !== 'idle' || abortRef.current || hasUnsettledTurn()) {
4150
+ pushMessage('system', 'Ctrl+L is unavailable while a turn is still in progress.');
4151
+ return;
4152
+ }
4153
+ setMessages([]);
4154
+ resetTranscript();
4155
+ return;
4156
+ }
3169
4157
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
3170
4158
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
3171
4159
  if (key.escape && abortRef.current) {
@@ -3225,7 +4213,7 @@ export function App({ ctx: initialCtx }) {
3225
4213
  return;
3226
4214
  }
3227
4215
  if (exitArmedRef.current) {
3228
- exit();
4216
+ exitWithSummary();
3229
4217
  return;
3230
4218
  }
3231
4219
  exitArmedRef.current = true;
@@ -3238,30 +4226,41 @@ export function App({ ctx: initialCtx }) {
3238
4226
  // Active on every phase. It was gated off during the picker, which is what
3239
4227
  // left that screen without a usable exit; the picker branch above returns
3240
4228
  // immediately, so the Picker still owns everything except Ctrl+C.
3241
- { isActive: true });
4229
+ { isActive: externalEditorRequest === null });
3242
4230
  // Background is left natural — we inherit the terminal's own background
3243
4231
  // and only theme the foreground. Forcing
3244
4232
  // a filled bg left mismatched patches around bordered areas, so we don't.
3245
- 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, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _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' &&
4233
+ 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 }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: stdout.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' &&
3246
4234
  phase === 'ready' &&
3247
4235
  conversationMutation === null &&
4236
+ textPrompt === null &&
3248
4237
  choicePicker === null &&
3249
- copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [queued.length > 0 &&
4238
+ copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
4239
+ permission === null &&
4240
+ textPrompt === null &&
4241
+ choicePicker === null &&
4242
+ 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 &&
3250
4243
  permission === null &&
4244
+ textPrompt === null &&
3251
4245
  choicePicker === null &&
3252
4246
  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
3253
4247
  ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
3254
4248
  : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
3255
4249
  state === 'awaiting-permission' ||
3256
4250
  compacting ||
3257
- conversationMutation !== null, hidden: permission !== null || choicePicker !== null || copyPicker !== null,
4251
+ conversationMutation !== null ||
4252
+ externalEditorRequest !== null, hidden: permission !== null ||
4253
+ textPrompt !== null ||
4254
+ choicePicker !== null ||
4255
+ copyPicker !== null,
3258
4256
  // A turn is running, so Esc is the interrupt and not
3259
4257
  // the composer's clear.
3260
- 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' &&
4258
+ 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' &&
3261
4259
  state === 'idle' &&
3262
4260
  conversationMutation === null &&
3263
4261
  !compacting &&
3264
4262
  permission === null &&
4263
+ textPrompt === null &&
3265
4264
  choicePicker === null &&
3266
4265
  copyPicker === null
3267
4266
  ? goalStatusLabel(goalStatus)
@@ -3269,19 +4268,23 @@ export function App({ ctx: initialCtx }) {
3269
4268
  ? 'forking conversation — input is paused'
3270
4269
  : conversationMutation === 'edit'
3271
4270
  ? 'branching before prompt — input is paused'
3272
- : conversationMutation === 'new'
3273
- ? 'starting a fresh conversation — input is paused'
3274
- : compacting
3275
- ? 'compacting conversation — input is paused'
3276
- : permission
3277
- ? hintForPhase(phase, state, session?.hasProvider === true)
3278
- : choicePicker
3279
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
3280
- : copyPicker
3281
- ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
3282
- : goalStatus && phase === 'ready' && state === 'idle'
3283
- ? undefined
3284
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
4271
+ : conversationMutation === 'archive'
4272
+ ? 'archiving conversation — input is paused'
4273
+ : conversationMutation === 'new'
4274
+ ? 'starting a fresh conversation — input is paused'
4275
+ : compacting
4276
+ ? 'compacting conversation — input is paused'
4277
+ : permission
4278
+ ? hintForPhase(phase, state, session?.hasProvider === true)
4279
+ : textPrompt
4280
+ ? 'name editor open — enter save · esc cancel'
4281
+ : choicePicker
4282
+ ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
4283
+ : copyPicker
4284
+ ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
4285
+ : goalStatus && phase === 'ready' && state === 'idle'
4286
+ ? undefined
4287
+ : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
3285
4288
  }
3286
4289
  function Banner({ version, session, permissionMode, cwd, }) {
3287
4290
  const cols = process.stdout.columns ?? 80;
@@ -3336,7 +4339,7 @@ canReturn = false) {
3336
4339
  if (state === 'awaiting-permission')
3337
4340
  return 'y approve · n / esc reject · a approve all';
3338
4341
  if (state !== 'idle')
3339
- return 'agent is working — esc to interrupt';
4342
+ return 'enter steer · tab queue · esc interrupt';
3340
4343
  // The composer already points to /help. Keeping the complete key legend in
3341
4344
  // every idle frame made ordinary conversation read like a permanent setup
3342
4345
  // screen; only state-specific actions belong in the footer.