@namzu/cli 14.3.0 → 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 (134) hide show
  1. package/README.md +107 -17
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +26 -26
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.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/run.d.ts.map +1 -1
  15. package/dist/commands/run.js +22 -9
  16. package/dist/commands/run.js.map +1 -1
  17. package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
  18. package/dist/commands/serve.d.ts.map +1 -0
  19. package/dist/commands/{stubs.js → serve.js} +1 -24
  20. package/dist/commands/serve.js.map +1 -0
  21. package/dist/commands/skills.d.ts +19 -0
  22. package/dist/commands/skills.d.ts.map +1 -0
  23. package/dist/commands/skills.js +107 -0
  24. package/dist/commands/skills.js.map +1 -0
  25. package/dist/integrations/mcp/servers.d.ts +7 -0
  26. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  27. package/dist/integrations/mcp/servers.js +33 -8
  28. package/dist/integrations/mcp/servers.js.map +1 -1
  29. package/dist/integrations/sessions/store.d.ts +8 -0
  30. package/dist/integrations/sessions/store.d.ts.map +1 -1
  31. package/dist/integrations/sessions/store.js +15 -0
  32. package/dist/integrations/sessions/store.js.map +1 -1
  33. package/dist/integrations/subagents/runtime.d.ts +2 -0
  34. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  35. package/dist/integrations/subagents/runtime.js +86 -13
  36. package/dist/integrations/subagents/runtime.js.map +1 -1
  37. package/dist/integrations/updates.d.ts.map +1 -1
  38. package/dist/integrations/updates.js +29 -4
  39. package/dist/integrations/updates.js.map +1 -1
  40. package/dist/tui/App.d.ts +10 -1
  41. package/dist/tui/App.d.ts.map +1 -1
  42. package/dist/tui/App.js +1003 -186
  43. package/dist/tui/App.js.map +1 -1
  44. package/dist/tui/ChoicePicker.d.ts +2 -1
  45. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  46. package/dist/tui/ChoicePicker.js +10 -6
  47. package/dist/tui/ChoicePicker.js.map +1 -1
  48. package/dist/tui/Composer.d.ts +19 -2
  49. package/dist/tui/Composer.d.ts.map +1 -1
  50. package/dist/tui/Composer.js +526 -70
  51. package/dist/tui/Composer.js.map +1 -1
  52. package/dist/tui/CopyPicker.d.ts.map +1 -1
  53. package/dist/tui/CopyPicker.js +3 -4
  54. package/dist/tui/CopyPicker.js.map +1 -1
  55. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  56. package/dist/tui/EditPromptPicker.js +3 -4
  57. package/dist/tui/EditPromptPicker.js.map +1 -1
  58. package/dist/tui/Markdown.d.ts +3 -1
  59. package/dist/tui/Markdown.d.ts.map +1 -1
  60. package/dist/tui/Markdown.js +11 -9
  61. package/dist/tui/Markdown.js.map +1 -1
  62. package/dist/tui/PermissionOverlay.d.ts +11 -6
  63. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  64. package/dist/tui/PermissionOverlay.js +13 -20
  65. package/dist/tui/PermissionOverlay.js.map +1 -1
  66. package/dist/tui/Picker.d.ts.map +1 -1
  67. package/dist/tui/Picker.js +47 -11
  68. package/dist/tui/Picker.js.map +1 -1
  69. package/dist/tui/ResumePicker.d.ts.map +1 -1
  70. package/dist/tui/ResumePicker.js +6 -1
  71. package/dist/tui/ResumePicker.js.map +1 -1
  72. package/dist/tui/TextPrompt.d.ts +21 -0
  73. package/dist/tui/TextPrompt.d.ts.map +1 -0
  74. package/dist/tui/TextPrompt.js +139 -0
  75. package/dist/tui/TextPrompt.js.map +1 -0
  76. package/dist/tui/Transcript.d.ts +3 -3
  77. package/dist/tui/Transcript.d.ts.map +1 -1
  78. package/dist/tui/Transcript.js +4 -4
  79. package/dist/tui/Transcript.js.map +1 -1
  80. package/dist/tui/agent.d.ts +24 -20
  81. package/dist/tui/agent.d.ts.map +1 -1
  82. package/dist/tui/agent.js +98 -74
  83. package/dist/tui/agent.js.map +1 -1
  84. package/dist/tui/exit-summary.d.ts.map +1 -1
  85. package/dist/tui/exit-summary.js +2 -1
  86. package/dist/tui/exit-summary.js.map +1 -1
  87. package/dist/tui/external-editor.d.ts +25 -0
  88. package/dist/tui/external-editor.d.ts.map +1 -0
  89. package/dist/tui/external-editor.js +169 -0
  90. package/dist/tui/external-editor.js.map +1 -0
  91. package/dist/tui/index.d.ts.map +1 -1
  92. package/dist/tui/index.js +4 -0
  93. package/dist/tui/index.js.map +1 -1
  94. package/dist/tui/login-prompt.d.ts +2 -0
  95. package/dist/tui/login-prompt.d.ts.map +1 -1
  96. package/dist/tui/login-prompt.js +8 -1
  97. package/dist/tui/login-prompt.js.map +1 -1
  98. package/dist/tui/mentions.d.ts +19 -0
  99. package/dist/tui/mentions.d.ts.map +1 -1
  100. package/dist/tui/mentions.js +96 -5
  101. package/dist/tui/mentions.js.map +1 -1
  102. package/dist/tui/permission-review.d.ts +59 -0
  103. package/dist/tui/permission-review.d.ts.map +1 -0
  104. package/dist/tui/permission-review.js +219 -0
  105. package/dist/tui/permission-review.js.map +1 -0
  106. package/dist/tui/selection-window.d.ts +18 -0
  107. package/dist/tui/selection-window.d.ts.map +1 -0
  108. package/dist/tui/selection-window.js +37 -0
  109. package/dist/tui/selection-window.js.map +1 -0
  110. package/dist/tui/slashCommands.d.ts +48 -8
  111. package/dist/tui/slashCommands.d.ts.map +1 -1
  112. package/dist/tui/slashCommands.js +129 -47
  113. package/dist/tui/slashCommands.js.map +1 -1
  114. package/dist/tui/terminal-hyperlinks.d.ts +20 -0
  115. package/dist/tui/terminal-hyperlinks.d.ts.map +1 -0
  116. package/dist/tui/terminal-hyperlinks.js +70 -0
  117. package/dist/tui/terminal-hyperlinks.js.map +1 -0
  118. package/dist/tui/types.d.ts +2 -0
  119. package/dist/tui/types.d.ts.map +1 -1
  120. package/dist/tui/use-selection-index.d.ts +8 -0
  121. package/dist/tui/use-selection-index.d.ts.map +1 -0
  122. package/dist/tui/use-selection-index.js +13 -0
  123. package/dist/tui/use-selection-index.js.map +1 -0
  124. package/dist/tui/workspace-review.d.ts +15 -0
  125. package/dist/tui/workspace-review.d.ts.map +1 -0
  126. package/dist/tui/workspace-review.js +67 -0
  127. package/dist/tui/workspace-review.js.map +1 -0
  128. package/dist/version.d.ts +10 -0
  129. package/dist/version.d.ts.map +1 -0
  130. package/dist/version.js +23 -0
  131. package/dist/version.js.map +1 -0
  132. package/package.json +6 -5
  133. package/dist/commands/stubs.d.ts.map +0 -1
  134. package/dist/commands/stubs.js.map +0 -1
package/dist/tui/App.js CHANGED
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * automatic reuse is not mistaken for a durable operator preference.
13
13
  */
14
14
  import { join, relative } from 'node:path';
15
- import { 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';
@@ -20,8 +20,8 @@ import { visibleProjectInstructionPath } from '../context/project-path.js';
20
20
  import { runtimeContextLabel } from '../context/runtime-message.js';
21
21
  import { writeClipboardText } from '../integrations/clipboard/text.js';
22
22
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
23
- import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
24
- import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
23
+ import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, clearStoredCodexCredential, clearStoredSubscriptionCredential, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
24
+ import { appendMessages, archiveConversation, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, loadResumableConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
25
25
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
26
26
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
27
27
  import { checkUpdates } from '../integrations/updates.js';
@@ -29,15 +29,18 @@ import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.j
29
29
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
30
30
  import { discoverUserCommands } from '../user-commands/store.js';
31
31
  import { ChoicePicker } from './ChoicePicker.js';
32
- import { Composer } from './Composer.js';
32
+ import { Composer, suggestionWindowSize, } from './Composer.js';
33
33
  import { CopyPicker } from './CopyPicker.js';
34
34
  import { EditPromptPicker } from './EditPromptPicker.js';
35
35
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
36
36
  import { PermissionOverlay } from './PermissionOverlay.js';
37
+ import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
37
38
  import { Picker } from './Picker.js';
38
39
  import { ResumePicker } from './ResumePicker.js';
39
40
  import { StatusBar } from './StatusBar.js';
41
+ import { TextPrompt } from './TextPrompt.js';
40
42
  import { Transcript, willCollapse } from './Transcript.js';
43
+ import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
41
44
  import { TrustPrompt } from './TrustPrompt.js';
42
45
  import { createAgentSession, probeAgentSession, } from './agent.js';
43
46
  import { bottomSpacerRows } from './bottom-spacer.js';
@@ -47,14 +50,18 @@ import { planTurnPublication } from './conversation-history.js';
47
50
  import { copyTargetsForResponse } from './copy-targets.js';
48
51
  import { editablePrompts } from './edit-prompts.js';
49
52
  import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
50
- import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
53
+ import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
51
54
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
52
- import { expandFileMentions } from './mentions.js';
55
+ import { expandFileMentions, listMentionableFiles } from './mentions.js';
53
56
  import { openInBrowser } from './open-browser.js';
54
- import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
57
+ import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
55
58
  import { splitCompleteBlocks } from './stream-blocks.js';
59
+ import { moveSelection } from './selection-window.js';
56
60
  import { theme } from './theme.js';
61
+ import { useSelectionIndex } from './use-selection-index.js';
62
+ import { editDraftInExternalEditor } from './external-editor.js';
57
63
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
64
+ import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
58
65
  /** Last non-empty assistant text in a durable conversation, newest first. */
59
66
  function latestAssistantOutput(messages) {
60
67
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -67,6 +74,12 @@ function latestAssistantOutput(messages) {
67
74
  }
68
75
  return null;
69
76
  }
77
+ /** Exact operator-authored prompts that belong in terminal input history. */
78
+ export function promptHistoryFromConversation(messages) {
79
+ return messages.flatMap((message) => message.role === 'user' && !message.source && message.content.trim().length > 0
80
+ ? [message.content]
81
+ : []);
82
+ }
70
83
  /** Build the transcript projection of exact model-visible history. */
71
84
  export function projectConversation(messages, nextId, readableUserTexts = []) {
72
85
  let userOrdinal = 0;
@@ -202,7 +215,8 @@ function goalRoundPrompt(authority) {
202
215
  * until the renderer gives up on incremental repaint. See `live-window.ts`.
203
216
  */
204
217
  const LIVE_FURNITURE_ROWS = 10;
205
- export function App({ ctx: initialCtx, onExitSummary }) {
218
+ const defaultExternalEditor = ({ seed, cwd, signal }) => editDraftInExternalEditor(seed, { cwd, signal });
219
+ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultExternalEditor, }) {
206
220
  // Bind approval and every operation it admits to one real directory. A
207
221
  // lexical cwd may be a writable symlink; resolving it again after the gate
208
222
  // would let it be repointed from the approved project to another.
@@ -217,6 +231,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
217
231
  }, [initialCtx]);
218
232
  const { exit } = useApp();
219
233
  const { stdout, write: writeStdout } = useStdout();
234
+ const hyperlinks = terminalSupportsHyperlinks(process.env, stdout.isTTY === true);
220
235
  /**
221
236
  * The last assistant message id the run reported, for `/feedback`.
222
237
  *
@@ -251,6 +266,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
251
266
  * has to pretend it is one.
252
267
  */
253
268
  const modelHistoryRef = useRef([]);
269
+ /** Consumed only after the exact durable conversation has loaded successfully. */
270
+ const initialConversationIdRef = useRef(initialCtx.initialConversationId);
254
271
  const [history, setHistory] = useState([]);
255
272
  const [state, setState] = useState('idle');
256
273
  const [phase, setPhase] = useState('probing');
@@ -284,6 +301,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
284
301
  */
285
302
  const [pickerNotice, setPickerNotice] = useState(null);
286
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
+ }, []);
287
310
  // The launch bypass is an INITIAL selection, not permanent authority. A
288
311
  // typed /permissions command can narrow it back to prompt/strict without
289
312
  // rebuilding the provider, tools, plugins, or sandbox.
@@ -302,6 +325,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
302
325
  // per keypress is a cost nobody asked for. A file added mid-session is
303
326
  // picked up by `/model` (which re-hydrates) or a restart.
304
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);
305
331
  const [usage, setUsage] = useState(null);
306
332
  // Context fill, straight from the kernel and held apart from `usage` —
307
333
  // they are different quantities and conflating them is what made the
@@ -371,14 +397,14 @@ export function App({ ctx: initialCtx, onExitSummary }) {
371
397
  const compactingRef = useRef(false);
372
398
  const [compacting, setCompacting] = useState(false);
373
399
  const [resumeList, setResumeList] = useState([]);
374
- const [selectedResume, setSelectedResume] = useState(0);
400
+ const { selection: selectedResume, selectionRef: selectedResumeRef, setSelection: setSelectedResume, } = useSelectionIndex(0);
375
401
  const [editList, setEditList] = useState([]);
376
- const [selectedEdit, setSelectedEdit] = useState(0);
402
+ const { selection: selectedEdit, selectionRef: selectedEditRef, setSelection: setSelectedEdit, } = useSelectionIndex(0);
377
403
  const editCommittedRef = useRef(false);
378
404
  /** `/copy` owns this exact response snapshot until selection or cancellation. */
379
405
  const [copyPicker, setCopyPickerState] = useState(null);
380
406
  const copyPickerRef = useRef(null);
381
- const [selectedCopy, setSelectedCopy] = useState(0);
407
+ const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
382
408
  const setCopyPicker = useCallback((next) => {
383
409
  copyPickerRef.current = next;
384
410
  setCopyPickerState(next);
@@ -392,7 +418,8 @@ export function App({ ctx: initialCtx, onExitSummary }) {
392
418
  * that created it before a menu has existed in the rendered tree.
393
419
  */
394
420
  const choicePickerCommittedRef = useRef(null);
395
- const [selectedChoice, setSelectedChoice] = useState(0);
421
+ const commandPickerSubmitRef = useRef(() => { });
422
+ const { selection: selectedChoice, selectionRef: selectedChoiceRef, setSelection: setSelectedChoice, } = useSelectionIndex(0);
396
423
  const setChoicePicker = useCallback((next) => {
397
424
  choicePickerRef.current = next;
398
425
  if (next === null)
@@ -402,8 +429,18 @@ export function App({ ctx: initialCtx, onExitSummary }) {
402
429
  useEffect(() => {
403
430
  choicePickerCommittedRef.current = choicePicker;
404
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
+ }, []);
405
440
  const [composerDraft, setComposerDraft] = useState(null);
406
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);
407
444
  const exitArmedRef = useRef(false);
408
445
  const abortRef = useRef(null);
409
446
  /** Identity of the turn allowed to notify when it settles. */
@@ -533,6 +570,10 @@ export function App({ ctx: initialCtx, onExitSummary }) {
533
570
  */
534
571
  const conversationMutationRef = useRef(null);
535
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);
536
577
  /** Closes the same-tick input window while a verified export reads disk. */
537
578
  const exportingRef = useRef(false);
538
579
  /** A goal mutation is ordered before any later conversation command. */
@@ -568,6 +609,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
568
609
  settledRef.current = 0;
569
610
  setResetKey((k) => k + 1);
570
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
+ }, []);
571
682
  const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
572
683
  const id = nextId();
573
684
  setMessages((prev) => [
@@ -650,6 +761,222 @@ export function App({ ctx: initialCtx, onExitSummary }) {
650
761
  setReasoningEffort(effort);
651
762
  pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
652
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]);
653
980
  const applyChoiceSelection = useCallback((index) => {
654
981
  const picker = choicePickerRef.current;
655
982
  if (!picker)
@@ -657,13 +984,211 @@ export function App({ ctx: initialCtx, onExitSummary }) {
657
984
  const value = picker.values[index];
658
985
  if (index < 0 || index >= picker.values.length)
659
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
+ }
660
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
+ }
661
1159
  if (picker.kind === 'permission-mode') {
662
1160
  applyPermissionMode(value);
663
1161
  return;
664
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
+ }
665
1175
  applyReasoningEffort(value);
666
- }, [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
+ ]);
667
1192
  const sendCopyRequest = useCallback((index) => {
668
1193
  const picker = copyPickerRef.current;
669
1194
  const target = picker?.targets[index];
@@ -734,14 +1259,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
734
1259
  const finalizeMessage = useCallback((id, finalContent) => {
735
1260
  setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
736
1261
  }, []);
737
- // Open the SDK session store + start a fresh conversation once. Best-effort:
738
- // on failure persistence is simply unavailable and the chat still works.
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.
739
1265
  const ensureSessions = useCallback(async () => {
740
1266
  if (scopeRef.current)
741
1267
  return scopeRef.current;
1268
+ const requestedConversationId = initialConversationIdRef.current;
742
1269
  try {
743
1270
  const sessions = await openSessions(ctxRef.current.cwd);
744
- 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
+ }
745
1287
  sessionsRef.current = sessions;
746
1288
  scopeRef.current = {
747
1289
  sessionId,
@@ -751,10 +1293,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
751
1293
  };
752
1294
  return scopeRef.current;
753
1295
  }
754
- catch {
1296
+ catch (error) {
1297
+ if (requestedConversationId)
1298
+ throw error;
755
1299
  return undefined;
756
1300
  }
757
- }, []);
1301
+ }, [nextId]);
758
1302
  const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
759
1303
  if (signal?.aborted)
760
1304
  return;
@@ -765,6 +1309,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
765
1309
  const s = await createAgentSession(prefs, detectedNow, {
766
1310
  scope,
767
1311
  cwd: activeCtx.cwd,
1312
+ enableComputerUse: true,
768
1313
  rules: activeCtx.rules,
769
1314
  ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
770
1315
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
@@ -805,6 +1350,13 @@ export function App({ ctx: initialCtx, onExitSummary }) {
805
1350
  // the file is shadowed instead of leaving it silently unused.
806
1351
  reserved: hostCommandNames(),
807
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
+ });
808
1360
  setCurrentProvider(primaryProvider(prefs).id);
809
1361
  // A picker-owned, usable provider switch is an explicit recovery from a
810
1362
  // paused failed turn. Failed and superseded candidates return above and
@@ -852,7 +1404,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
852
1404
  if (s.errorHint)
853
1405
  pushMessage('system', s.errorHint);
854
1406
  }
855
- }, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
1407
+ }, [appLifetime.signal, advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
856
1408
  /**
857
1409
  * Sign in to a subscription without leaving namzu.
858
1410
  *
@@ -1015,6 +1567,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1015
1567
  try {
1016
1568
  if (signal?.aborted)
1017
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
+ }
1018
1583
  const probe = await probeAgentSession();
1019
1584
  if (signal?.aborted)
1020
1585
  return;
@@ -1102,7 +1667,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1102
1667
  setPhase('unhealthy');
1103
1668
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
1104
1669
  }
1105
- }, [appLifetime.signal, hydrateSession, pushMessage]);
1670
+ }, [appLifetime.signal, ensureSessions, hydrateSession, pushMessage]);
1106
1671
  // `startOrFinishLogin` is declared above `runProbe` and calls it, so it
1107
1672
  // reads the current one through a ref rather than closing over a stale
1108
1673
  // binding or forcing the two into a declaration order that reads backwards.
@@ -1169,7 +1734,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1169
1734
  // the answer is knowable. `liveRows` is the furniture beneath the transcript
1170
1735
  // PLUS the live window above it — the window is part of the live region, and
1171
1736
  // leaving it out would have the spacer padding room that is already taken.
1172
- const spacerCandidate = phase === 'ready'
1737
+ const spacerCandidate = phase === 'ready' && permission === null
1173
1738
  ? bottomSpacerRows({
1174
1739
  rows: stdout.rows,
1175
1740
  columns: stdout.columns,
@@ -1186,11 +1751,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1186
1751
  .slice(-MAX_LIVE_ROWS)
1187
1752
  .map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
1188
1753
  };
1189
- const spacerRows = phase === 'ready' && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
1754
+ const spacerRows = phase === 'ready' && permission === null && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
1190
1755
  ? spacerLayoutRef.current.spacerRows
1191
1756
  : spacerCandidate;
1192
1757
  spacerLayoutRef.current =
1193
- phase === 'ready' ? { ...spacerLayout, spacerRows } : null;
1758
+ phase === 'ready' && permission === null ? { ...spacerLayout, spacerRows } : null;
1194
1759
  // One merged vocabulary for the session: this host's own commands plus
1195
1760
  // whatever the kernel's registry reports. Built here so `/help`, the
1196
1761
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1198,6 +1763,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1198
1763
  // disagree the moment a capability added a command.
1199
1764
  const hostCommands = mergeHostCommands(kernelCommandDescriptors());
1200
1765
  const slashCtx = {
1766
+ cwd: ctx.cwd,
1201
1767
  builtins: hostCommands,
1202
1768
  lastAssistantMessageId: () => lastAssistantMessage.current?.messageId ?? null,
1203
1769
  // Called when `/tools` renders, not read here — the same shape, and the
@@ -1206,7 +1772,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1206
1772
  // From the session, not re-resolved: resolving builds a provider, and a
1207
1773
  // second one would describe a different sandbox than the run is using.
1208
1774
  sandbox: session?.sandbox ?? null,
1209
- 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,
1210
1781
  providerSummary: session?.providerSummary ?? null,
1211
1782
  modelSummary: session?.modelSummary ?? null,
1212
1783
  reasoningEffort: {
@@ -1269,10 +1840,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1269
1840
  const resolve = permissionResolveRef.current;
1270
1841
  permissionResolveRef.current = null;
1271
1842
  permissionOpenedAtRef.current = null;
1843
+ setPermissionReviewOffset(0);
1272
1844
  setPermission(null);
1273
1845
  if (resolve)
1274
1846
  resolve(decision);
1275
- }, []);
1847
+ }, [setPermissionReviewOffset]);
1276
1848
  /**
1277
1849
  * Stop the running turn, and hand the screen back in a usable state.
1278
1850
  *
@@ -1297,7 +1869,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1297
1869
  const ac = abortRef.current;
1298
1870
  if (!ac)
1299
1871
  return false;
1300
- ac.abort();
1872
+ ac.abort(new RunCancelled('user'));
1301
1873
  const activeSessionId = scopeRef.current?.sessionId;
1302
1874
  if (activeSessionId)
1303
1875
  goalActivation.disarm(activeSessionId);
@@ -1390,6 +1962,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1390
1962
  resetTranscript();
1391
1963
  setMessages(restored);
1392
1964
  modelHistoryRef.current = msgs;
1965
+ setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
1393
1966
  const persistedOutput = latestAssistantOutput(msgs);
1394
1967
  lastCompletedOutputRef.current = persistedOutput
1395
1968
  ? { text: persistedOutput, provenance: 'persisted' }
@@ -1486,11 +2059,12 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1486
2059
  }
1487
2060
  }, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
1488
2061
  /**
1489
- * `/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.
1490
2064
  *
1491
- * A bare `/title` asks rather than clears. Erasing a name by pressing
1492
- * enter on a half-typed command is the kind of loss nobody notices until
1493
- * `/resume` is a list of opening messages again.
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.
1494
2068
  */
1495
2069
  const doTitle = useCallback(async (title, clear) => {
1496
2070
  const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
@@ -1502,9 +2076,16 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1502
2076
  try {
1503
2077
  if (!clear && title === '') {
1504
2078
  const current = titleOf(sessions, scope.sessionId);
1505
- pushMessage('system', current !== undefined
1506
- ? `This conversation is named "${current}". /title <name> renames it; /title clear removes the name.`
1507
- : 'This conversation has no name, so /resume lists it by its opening message — which stops describing it as the conversation moves on. /title <name> fixes that.');
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
+ });
1508
2089
  return;
1509
2090
  }
1510
2091
  setTitle(sessions, scope.sessionId, clear ? '' : title);
@@ -1515,7 +2096,46 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1515
2096
  catch (err) {
1516
2097
  pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
1517
2098
  }
1518
- }, [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]);
1519
2139
  /**
1520
2140
  * `/fork`: continue in a copy, leaving this conversation where it is.
1521
2141
  *
@@ -1671,13 +2291,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
1671
2291
  ]);
1672
2292
  // Bridge passed into session.send(): the agent calls this before a
1673
2293
  // non-read-only tool batch; it parks until the user presses y/n/a.
1674
- const onPermission = useCallback((req) => new Promise((resolve) => {
1675
- permissionResolveRef.current = resolve;
1676
- permissionOpenedAtRef.current = Date.now();
1677
- setPermission(req);
1678
- setState('awaiting-permission');
1679
- sendTerminalNotification({ kind: 'approval-required' });
1680
- }), [sendTerminalNotification]);
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]);
1681
2311
  // Render one agent event onto the transcript. Shared by the local turn loop
1682
2312
  // and the daemon-attach poller, so both paths produce identical output.
1683
2313
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -2268,7 +2898,9 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2268
2898
  ? 'forked'
2269
2899
  : conversationMutationRef.current === 'edit'
2270
2900
  ? 'branched for prompt editing'
2271
- : 'moved to a fresh conversation';
2901
+ : conversationMutationRef.current === 'archive'
2902
+ ? 'archived'
2903
+ : 'moved to a fresh conversation';
2272
2904
  pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
2273
2905
  return;
2274
2906
  }
@@ -2284,13 +2916,74 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2284
2916
  case 'message':
2285
2917
  pushMessage(slash.role, slash.content);
2286
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
+ }
2287
2940
  case 'clear-screen':
2288
2941
  setMessages([]);
2289
2942
  resetTranscript();
2290
2943
  return;
2944
+ case 'composer-draft':
2945
+ composerDraftTokenRef.current += 1;
2946
+ setComposerDraft({
2947
+ token: composerDraftTokenRef.current,
2948
+ text: slash.text,
2949
+ });
2950
+ return;
2291
2951
  case 'new-conversation':
2292
2952
  void startFreshConversation(slash.clearScreen);
2293
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
+ }
2294
2987
  case 'exit':
2295
2988
  exitWithSummary();
2296
2989
  return;
@@ -2377,16 +3070,35 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2377
3070
  void startOrFinishLogin(slash.pasted);
2378
3071
  return;
2379
3072
  case 'logout': {
2380
- const path = credentialsPath();
2381
- const had = readStoredSubscriptionCredential() !== null || readStoredCodexCredential() !== null;
2382
- try {
2383
- clearAllStoredCredentials();
3073
+ if (slash.target) {
3074
+ removeStoredCredential(slash.target);
3075
+ return;
2384
3076
  }
2385
- catch (err) {
2386
- 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');
2387
3083
  return;
2388
3084
  }
2389
- pushMessage('system', describeLogout(path, had));
3085
+ if (choices.length === 1) {
3086
+ removeStoredCredential(choices[0]);
3087
+ return;
3088
+ }
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
+ });
2390
3102
  return;
2391
3103
  }
2392
3104
  case 'remember':
@@ -2424,23 +3136,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2424
3136
  pushMessage('system', `Skills (● active):\n ${lines.join('\n ')}`);
2425
3137
  return;
2426
3138
  }
2427
- case 'load-skill': {
2428
- const info = discoverSkills({ cwd: ctx.cwd }).find((s) => s.name === slash.name);
2429
- if (!info) {
2430
- pushMessage('system', `No skill named "${slash.name}". See /skills.`);
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.');
2431
3143
  return;
2432
3144
  }
2433
- try {
2434
- const body = loadSkillBody(info);
2435
- setActiveSkills((prev) => [
2436
- ...prev.filter((s) => s.name !== info.name),
2437
- { name: info.name, body },
2438
- ]);
2439
- pushMessage('system', `Activated skill: ${info.name}`);
2440
- }
2441
- catch (err) {
2442
- pushMessage('system', `Could not load skill "${slash.name}": ${err instanceof Error ? err.message : String(err)}`);
2443
- }
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);
2444
3163
  return;
2445
3164
  }
2446
3165
  case 'resume':
@@ -2586,67 +3305,76 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2586
3305
  })();
2587
3306
  return;
2588
3307
  }
2589
- case 'feedback': {
3308
+ case 'feedback-picker': {
2590
3309
  const target = lastAssistantMessage.current;
2591
- // Written under the same `<cwd>/.namzu` root the runs live
2592
- // in, so a rating and the transcript it judges are one
2593
- // directory apart and travel together.
2594
- if (!target) {
3310
+ if (!target || target.messageId !== slash.messageId) {
2595
3311
  pushMessage('system', 'Nothing to rate yet.');
2596
3312
  return;
2597
3313
  }
2598
- const store = new DiskMessageFeedbackStore({
2599
- rootDir: join(ctx.cwd, '.namzu', 'feedback'),
2600
- 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
+ ],
2601
3329
  });
2602
- const runId = target.runId;
2603
- const messageId = slash.messageId;
2604
- // Fire-and-forget: this switch is synchronous, and the
2605
- // transcript reports the outcome either way rather than
2606
- // blocking a keystroke on a disk write.
2607
- void (async () => {
2608
- try {
2609
- // A first rating expects nothing; a second replaces the
2610
- // first, which is what a rater changing their mind does.
2611
- // Read-then-write rather than blind overwrite, so two
2612
- // raters on one message still collide loudly.
2613
- const current = (await store.listMessageFeedback({ runId })).find((r) => r.messageId === messageId);
2614
- const record = await store.putMessageFeedback({
2615
- runId,
2616
- messageId,
2617
- rating: slash.rating,
2618
- ...(slash.note ? { note: slash.note } : {}),
2619
- expectedVersion: current?.ownerVersion ?? 0,
2620
- });
2621
- pushMessage('system', `Recorded ${record.rating} for ${record.messageId}.`);
2622
- }
2623
- catch (err) {
2624
- pushMessage('system', `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`);
2625
- }
2626
- })();
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);
2627
3339
  return;
2628
3340
  }
2629
3341
  case 'review': {
2630
- void (async () => {
2631
- const diff = await workspaceDiff(ctx.cwd);
2632
- if (diff === null) {
2633
- pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
2634
- return;
2635
- }
2636
- if (diff.stat.length === 0 && diff.untracked.length === 0) {
2637
- // Refused rather than sent. A review request over an
2638
- // unchanged tree burns a turn and comes back with a
2639
- // review of nothing, which reads exactly like a review
2640
- // of something.
2641
- pushMessage('system', 'Nothing to review — the working tree is clean.');
2642
- return;
2643
- }
2644
- // The same FIFO a typed message enters. A review composed
2645
- // while a turn is in flight must not jump the queue, and
2646
- // must not be dropped either.
2647
- const text = reviewPrompt(diff.stat, diff.untracked);
2648
- enqueueQueued({ kind: 'human', text });
2649
- })();
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
+ });
2650
3378
  return;
2651
3379
  }
2652
3380
  case 'diff': {
@@ -2771,45 +3499,30 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2771
3499
  : 'Raw output mode off — rich transcript rendering restored.');
2772
3500
  return;
2773
3501
  }
2774
- case 'export': {
2775
- const sessions = sessionsRef.current;
2776
- const destination = scopeRef.current?.sessionId;
2777
- if (!sessions || !destination) {
2778
- pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
3502
+ case 'export-picker': {
3503
+ if (!stableExportSource())
2779
3504
  return;
2780
- }
2781
- if (abortRef.current ||
2782
- state !== 'idle' ||
2783
- hasUnsettledTurn() ||
2784
- compactingRef.current ||
2785
- queuedRef.current.length > 0) {
2786
- pushMessage('system', 'A turn, compaction, or queued prompt is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
2787
- return;
2788
- }
2789
- exportingRef.current = true;
2790
- setState('thinking');
2791
- const generation = conversationGenRef.current;
2792
- const path = slash.path ?? `namzu-conversation-${destination}.md`;
2793
- void (async () => {
2794
- try {
2795
- await persistenceTailRef.current;
2796
- if (conversationGenRef.current !== generation ||
2797
- scopeRef.current?.sessionId !== destination) {
2798
- throw new Error('The active conversation changed before the export reached its durable boundary.');
2799
- }
2800
- const projected = await conversationMarkdown(sessions, destination);
2801
- const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
2802
- pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
2803
- }
2804
- catch (err) {
2805
- pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
2806
- }
2807
- finally {
2808
- exportingRef.current = false;
2809
- if (conversationGenRef.current === generation)
2810
- setState('idle');
2811
- }
2812
- })();
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 });
2813
3526
  return;
2814
3527
  }
2815
3528
  default: {
@@ -2865,14 +3578,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2865
3578
  nextId,
2866
3579
  pushMessage,
2867
3580
  rawOutput,
3581
+ removeStoredCredential,
2868
3582
  resetTranscript,
3583
+ runConversationExport,
2869
3584
  setChoicePicker,
2870
3585
  setCopyPicker,
2871
3586
  setReasoningEffort,
2872
3587
  slashCtx,
3588
+ stableExportSource,
2873
3589
  startFreshConversation,
2874
3590
  state,
3591
+ stdout.rows,
2875
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;
2876
3598
  // Keep the footer on the same durable goal record the driver mutates. A ref
2877
3599
  // alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
2878
3600
  // generation/session checks stop a late disk read labelling a resumed chat.
@@ -2925,6 +3647,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2925
3647
  conversationMutationRef.current ||
2926
3648
  exportingRef.current ||
2927
3649
  compactingRef.current ||
3650
+ textPromptRef.current ||
2928
3651
  choicePickerRef.current ||
2929
3652
  copyPickerRef.current ||
2930
3653
  goalCommandInFlightRef.current) {
@@ -2951,6 +3674,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
2951
3674
  conversationMutationRef.current ||
2952
3675
  exportingRef.current ||
2953
3676
  compactingRef.current ||
3677
+ textPromptRef.current ||
2954
3678
  choicePickerRef.current ||
2955
3679
  copyPickerRef.current ||
2956
3680
  goalCommandInFlightRef.current ||
@@ -3010,6 +3734,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3010
3734
  conversationMutation,
3011
3735
  session,
3012
3736
  state,
3737
+ textPrompt,
3013
3738
  choicePicker,
3014
3739
  copyPicker,
3015
3740
  wakeGoalDriver,
@@ -3026,6 +3751,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3026
3751
  conversationMutationRef.current ||
3027
3752
  exportingRef.current ||
3028
3753
  compactingRef.current ||
3754
+ textPromptRef.current ||
3029
3755
  choicePickerRef.current ||
3030
3756
  copyPickerRef.current ||
3031
3757
  goalCommandInFlightRef.current)
@@ -3042,6 +3768,7 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3042
3768
  phase,
3043
3769
  queued,
3044
3770
  queuePause,
3771
+ textPrompt,
3045
3772
  choicePicker,
3046
3773
  copyPicker,
3047
3774
  dequeueQueued,
@@ -3195,16 +3922,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3195
3922
  setPhase('ready');
3196
3923
  return;
3197
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
+ }
3198
3935
  if (key.escape || key.leftArrow || key.upArrow) {
3199
- setSelectedEdit((index) => Math.max(0, index - 1));
3936
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'previous'));
3200
3937
  return;
3201
3938
  }
3202
3939
  if (key.rightArrow || key.downArrow) {
3203
- setSelectedEdit((index) => Math.min(editList.length - 1, index + 1));
3940
+ setSelectedEdit((index) => moveSelection(index, editList.length, 'next'));
3204
3941
  return;
3205
3942
  }
3206
3943
  if (key.return) {
3207
- const target = editList[selectedEdit];
3944
+ const target = editList[selectedEditRef.current];
3208
3945
  if (target)
3209
3946
  void confirmPromptEdit(target);
3210
3947
  }
@@ -3218,12 +3955,21 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3218
3955
  // replaced regardless.
3219
3956
  if (resumeCommittedRef.current)
3220
3957
  return;
3221
- if (key.upArrow)
3222
- 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'));
3223
3969
  else if (key.downArrow)
3224
- setSelectedResume((i) => Math.min(resumeList.length - 1, i + 1));
3970
+ setSelectedResume((index) => moveSelection(index, resumeList.length, 'next'));
3225
3971
  else if (key.return) {
3226
- const conv = resumeList[selectedResume];
3972
+ const conv = resumeList[selectedResumeRef.current];
3227
3973
  if (conv)
3228
3974
  void resumeConversation(conv);
3229
3975
  }
@@ -3260,12 +4006,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3260
4006
  }
3261
4007
  // A pending permission prompt owns the keyboard: y/n/a decide it.
3262
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
+ }
3263
4028
  const ch = input.toLowerCase();
3264
4029
  // Refusing is never deferred or gated — it is the direction a
3265
4030
  // mistake is recoverable in, so it answers on the first press.
3266
4031
  if (key.ctrl && input === 'c') {
3267
4032
  resolvePermission({ kind: 'reject', feedback: 'User interrupted.' });
3268
- abortRef.current?.abort();
4033
+ abortRef.current?.abort(new RunCancelled('user'));
3269
4034
  return;
3270
4035
  }
3271
4036
  if (ch === 'n' || key.escape) {
@@ -3286,6 +4051,11 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3286
4051
  resolvePermission({ kind: 'approve-all' });
3287
4052
  return;
3288
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;
3289
4059
  // Finite slash-command choices own the keyboard below a live permission
3290
4060
  // request and above the ordinary composer. Their values are a captured
3291
4061
  // menu, so a provider/session change cannot silently reinterpret Enter.
@@ -3299,19 +4069,31 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3299
4069
  return;
3300
4070
  const options = picker.options;
3301
4071
  if (key.escape || (key.ctrl && input === 'c')) {
4072
+ reviewChoiceInFlightRef.current = null;
3302
4073
  setChoicePicker(null);
3303
4074
  return;
3304
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
+ }
3305
4087
  if (key.upArrow) {
3306
- setSelectedChoice((index) => Math.max(0, index - 1));
4088
+ setSelectedChoice((index) => moveSelection(index, options.length, 'previous'));
3307
4089
  return;
3308
4090
  }
3309
4091
  if (key.downArrow) {
3310
- setSelectedChoice((index) => Math.min(options.length - 1, index + 1));
4092
+ setSelectedChoice((index) => moveSelection(index, options.length, 'next'));
3311
4093
  return;
3312
4094
  }
3313
4095
  if (key.return) {
3314
- applyChoiceSelection(selectedChoice);
4096
+ applyChoiceSelection(selectedChoiceRef.current);
3315
4097
  return;
3316
4098
  }
3317
4099
  if (/^[1-9]$/.test(input)) {
@@ -3330,16 +4112,26 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3330
4112
  setCopyPicker(null);
3331
4113
  return;
3332
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
+ }
3333
4125
  if (key.upArrow) {
3334
- setSelectedCopy((index) => Math.max(0, index - 1));
4126
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'previous'));
3335
4127
  return;
3336
4128
  }
3337
4129
  if (key.downArrow) {
3338
- setSelectedCopy((index) => Math.min(targets.length - 1, index + 1));
4130
+ setSelectedCopy((index) => moveSelection(index, targets.length, 'next'));
3339
4131
  return;
3340
4132
  }
3341
4133
  if (key.return) {
3342
- sendCopyRequest(selectedCopy);
4134
+ sendCopyRequest(selectedCopyRef.current);
3343
4135
  return;
3344
4136
  }
3345
4137
  if (/^[1-9]$/.test(input)) {
@@ -3349,6 +4141,19 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3349
4141
  }
3350
4142
  return;
3351
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
+ }
3352
4157
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
3353
4158
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
3354
4159
  if (key.escape && abortRef.current) {
@@ -3421,33 +4226,41 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3421
4226
  // Active on every phase. It was gated off during the picker, which is what
3422
4227
  // left that screen without a usable exit; the picker branch above returns
3423
4228
  // immediately, so the Picker still owns everything except Ctrl+C.
3424
- { isActive: true });
4229
+ { isActive: externalEditorRequest === null });
3425
4230
  // Background is left natural — we inherit the terminal's own background
3426
4231
  // and only theme the foreground. Forcing
3427
4232
  // a filled bg left mismatched patches around bordered areas, so we don't.
3428
- return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, spacerRows: spacerRows, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls })) : choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice })) : copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
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' &&
3429
4234
  phase === 'ready' &&
3430
4235
  conversationMutation === null &&
4236
+ textPrompt === null &&
3431
4237
  choicePicker === null &&
3432
4238
  copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
3433
4239
  permission === null &&
4240
+ textPrompt === null &&
3434
4241
  choicePicker === null &&
3435
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 &&
3436
4243
  permission === null &&
4244
+ textPrompt === null &&
3437
4245
  choicePicker === null &&
3438
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
3439
4247
  ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
3440
4248
  : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
3441
4249
  state === 'awaiting-permission' ||
3442
4250
  compacting ||
3443
- 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,
3444
4256
  // A turn is running, so Esc is the interrupt and not
3445
4257
  // the composer's clear.
3446
- escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: phase === 'ready' &&
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' &&
3447
4259
  state === 'idle' &&
3448
4260
  conversationMutation === null &&
3449
4261
  !compacting &&
3450
4262
  permission === null &&
4263
+ textPrompt === null &&
3451
4264
  choicePicker === null &&
3452
4265
  copyPicker === null
3453
4266
  ? goalStatusLabel(goalStatus)
@@ -3455,19 +4268,23 @@ export function App({ ctx: initialCtx, onExitSummary }) {
3455
4268
  ? 'forking conversation — input is paused'
3456
4269
  : conversationMutation === 'edit'
3457
4270
  ? 'branching before prompt — input is paused'
3458
- : conversationMutation === 'new'
3459
- ? 'starting a fresh conversation — input is paused'
3460
- : compacting
3461
- ? 'compacting conversation — input is paused'
3462
- : permission
3463
- ? hintForPhase(phase, state, session?.hasProvider === true)
3464
- : choicePicker
3465
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
3466
- : copyPicker
3467
- ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
3468
- : goalStatus && phase === 'ready' && state === 'idle'
3469
- ? undefined
3470
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
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 }) })] }) }));
3471
4288
  }
3472
4289
  function Banner({ version, session, permissionMode, cwd, }) {
3473
4290
  const cols = process.stdout.columns ?? 80;