@namzu/cli 14.2.1 → 14.3.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 (63) hide show
  1. package/README.md +23 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +7 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/prior-messages.d.ts.map +1 -1
  6. package/dist/commands/prior-messages.js +13 -4
  7. package/dist/commands/prior-messages.js.map +1 -1
  8. package/dist/commands/upgrade.d.ts +25 -0
  9. package/dist/commands/upgrade.d.ts.map +1 -0
  10. package/dist/commands/upgrade.js +182 -0
  11. package/dist/commands/upgrade.js.map +1 -0
  12. package/dist/context/runtime-message.d.ts +4 -0
  13. package/dist/context/runtime-message.d.ts.map +1 -0
  14. package/dist/context/runtime-message.js +20 -0
  15. package/dist/context/runtime-message.js.map +1 -0
  16. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  17. package/dist/integrations/sessions/transcript-export.js +10 -1
  18. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  19. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -1
  20. package/dist/integrations/sessions/turn-evidence.js +9 -1
  21. package/dist/integrations/sessions/turn-evidence.js.map +1 -1
  22. package/dist/integrations/updates.d.ts +7 -1
  23. package/dist/integrations/updates.d.ts.map +1 -1
  24. package/dist/integrations/updates.js +59 -21
  25. package/dist/integrations/updates.js.map +1 -1
  26. package/dist/tui/App.d.ts +3 -1
  27. package/dist/tui/App.d.ts.map +1 -1
  28. package/dist/tui/App.js +213 -27
  29. package/dist/tui/App.js.map +1 -1
  30. package/dist/tui/Composer.d.ts +15 -5
  31. package/dist/tui/Composer.d.ts.map +1 -1
  32. package/dist/tui/Composer.js +73 -8
  33. package/dist/tui/Composer.js.map +1 -1
  34. package/dist/tui/LiveActivity.d.ts +3 -0
  35. package/dist/tui/LiveActivity.d.ts.map +1 -1
  36. package/dist/tui/LiveActivity.js +4 -1
  37. package/dist/tui/LiveActivity.js.map +1 -1
  38. package/dist/tui/Picker.d.ts.map +1 -1
  39. package/dist/tui/Picker.js +40 -9
  40. package/dist/tui/Picker.js.map +1 -1
  41. package/dist/tui/Transcript.d.ts +3 -1
  42. package/dist/tui/Transcript.d.ts.map +1 -1
  43. package/dist/tui/Transcript.js +2 -2
  44. package/dist/tui/Transcript.js.map +1 -1
  45. package/dist/tui/agent.d.ts +15 -0
  46. package/dist/tui/agent.d.ts.map +1 -1
  47. package/dist/tui/agent.js +17 -0
  48. package/dist/tui/agent.js.map +1 -1
  49. package/dist/tui/exit-summary.d.ts +6 -0
  50. package/dist/tui/exit-summary.d.ts.map +1 -0
  51. package/dist/tui/exit-summary.js +8 -0
  52. package/dist/tui/exit-summary.js.map +1 -0
  53. package/dist/tui/index.d.ts.map +1 -1
  54. package/dist/tui/index.js +15 -6
  55. package/dist/tui/index.js.map +1 -1
  56. package/dist/tui/log-pane.d.ts +13 -8
  57. package/dist/tui/log-pane.d.ts.map +1 -1
  58. package/dist/tui/log-pane.js +23 -29
  59. package/dist/tui/log-pane.js.map +1 -1
  60. package/dist/tui/open-browser.d.ts.map +1 -1
  61. package/dist/tui/open-browser.js +27 -9
  62. package/dist/tui/open-browser.js.map +1 -1
  63. package/package.json +6 -6
package/dist/tui/App.js CHANGED
@@ -17,6 +17,7 @@ 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
23
  import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
@@ -45,7 +46,7 @@ import { approvalIsDeliberate } from './consent-timing.js';
45
46
  import { planTurnPublication } from './conversation-history.js';
46
47
  import { copyTargetsForResponse } from './copy-targets.js';
47
48
  import { editablePrompts } from './edit-prompts.js';
48
- import { liveWindow, transcriptLines } from './live-window.js';
49
+ import { MAX_LIVE_ROWS, liveWindow, transcriptLines } from './live-window.js';
49
50
  import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
50
51
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
51
52
  import { expandFileMentions } from './mentions.js';
@@ -93,6 +94,17 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
93
94
  },
94
95
  ];
95
96
  }
97
+ if (message.source?.type === 'runtime-context') {
98
+ return [
99
+ {
100
+ id: nextId(),
101
+ role: 'system',
102
+ content: runtimeContextLabel(message.source.kind),
103
+ glyph: '↳',
104
+ detail: [message.content],
105
+ },
106
+ ];
107
+ }
96
108
  const readable = readableUserTexts[userOrdinal];
97
109
  userOrdinal += 1;
98
110
  return [
@@ -126,6 +138,44 @@ export function projectConversation(messages, nextId, readableUserTexts = []) {
126
138
  return [];
127
139
  });
128
140
  }
141
+ function sameSpacerLayout(previous, current) {
142
+ if (previous === null ||
143
+ previous.rows !== current.rows ||
144
+ previous.columns !== current.columns ||
145
+ previous.raw !== current.raw ||
146
+ previous.messageCount !== current.messageCount ||
147
+ previous.tail.length !== current.tail.length)
148
+ return false;
149
+ return current.tail.every((message, index) => {
150
+ const prior = previous.tail[index];
151
+ return (prior?.id === message.id &&
152
+ prior.content === message.content &&
153
+ prior.meta === message.meta &&
154
+ prior.detail === message.detail);
155
+ });
156
+ }
157
+ /** Put an undelivered steer back where it occurred among Tab-queued prompts. */
158
+ function mergeUndeliveredLiveInput(queued, undelivered) {
159
+ const merged = [...queued];
160
+ let inserted = 0;
161
+ for (const input of undelivered) {
162
+ const index = Math.min(input.queueBoundary + inserted, merged.length);
163
+ merged.splice(index, 0, input.prompt);
164
+ inserted += 1;
165
+ }
166
+ return merged;
167
+ }
168
+ function humanPromptMeta(attachedFiles, attachments, live = false) {
169
+ const parts = [];
170
+ if (attachedFiles > 0)
171
+ parts.push(`${attachedFiles} file${attachedFiles > 1 ? 's' : ''} attached`);
172
+ if (attachments && attachments.length > 0) {
173
+ parts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
174
+ }
175
+ if (live)
176
+ parts.push('steered current turn');
177
+ return parts.length > 0 ? parts.join(' · ') : undefined;
178
+ }
129
179
  function goalRoundPrompt(authority) {
130
180
  return [
131
181
  'Admitted session goal round (JSON; the objective field is operator-authored data):',
@@ -152,7 +202,7 @@ function goalRoundPrompt(authority) {
152
202
  * until the renderer gives up on incremental repaint. See `live-window.ts`.
153
203
  */
154
204
  const LIVE_FURNITURE_ROWS = 10;
155
- export function App({ ctx: initialCtx }) {
205
+ export function App({ ctx: initialCtx, onExitSummary }) {
156
206
  // Bind approval and every operation it admits to one real directory. A
157
207
  // lexical cwd may be a writable symlink; resolving it again after the gate
158
208
  // would let it be repointed from the approved project to another.
@@ -271,8 +321,12 @@ export function App({ ctx: initialCtx }) {
271
321
  * hold the window shut for the length of the next conversation.
272
322
  */
273
323
  const settledRef = useRef(0);
324
+ /** Keeps an in-place detail toggle from moving the rows above that detail. */
325
+ const spacerLayoutRef = useRef(null);
274
326
  // Complete prompts waiting for the one queue pump that may start a turn.
275
327
  const [queued, setQueued] = useState([]);
328
+ /** Return-submitted prompts accepted by the active turn but not yet drained by the SDK. */
329
+ const [pendingSteers, setPendingSteers] = useState([]);
276
330
  // Kept synchronous with the rendered queue so a turn's `finally` can decide
277
331
  // whether it is truly the last turn. React state captured when the turn
278
332
  // started would still say "empty" after the operator queued a follow-up.
@@ -332,17 +386,30 @@ export function App({ ctx: initialCtx }) {
332
386
  /** Finite slash-command choice owned synchronously until apply or cancel. */
333
387
  const [choicePicker, setChoicePickerState] = useState(null);
334
388
  const choicePickerRef = useRef(null);
389
+ /**
390
+ * Exact chooser React has committed. The synchronous owner ref is needed to
391
+ * block queue/goal work immediately, but it must not accept the Return key
392
+ * that created it before a menu has existed in the rendered tree.
393
+ */
394
+ const choicePickerCommittedRef = useRef(null);
335
395
  const [selectedChoice, setSelectedChoice] = useState(0);
336
396
  const setChoicePicker = useCallback((next) => {
337
397
  choicePickerRef.current = next;
398
+ if (next === null)
399
+ choicePickerCommittedRef.current = null;
338
400
  setChoicePickerState(next);
339
401
  }, []);
402
+ useEffect(() => {
403
+ choicePickerCommittedRef.current = choicePicker;
404
+ }, [choicePicker]);
340
405
  const [composerDraft, setComposerDraft] = useState(null);
341
406
  const composerDraftTokenRef = useRef(0);
342
407
  const exitArmedRef = useRef(false);
343
408
  const abortRef = useRef(null);
344
409
  /** Identity of the turn allowed to notify when it settles. */
345
410
  const activeTurnTokenRef = useRef(null);
411
+ /** Exact live-input owner; absent before provider admission and after settlement. */
412
+ const activeTurnInboxRef = useRef(null);
346
413
  /** A broken terminal notification is reported once, not after every turn. */
347
414
  const notificationFailureShownRef = useRef(false);
348
415
  /**
@@ -404,6 +471,12 @@ export function App({ ctx: initialCtx }) {
404
471
  // turns attribute to the resumed conversation.
405
472
  const sessionsRef = useRef(null);
406
473
  const scopeRef = useRef(null);
474
+ const exitWithSummary = useCallback(() => {
475
+ onExitSummary?.({
476
+ ...(scopeRef.current?.sessionId ? { conversationId: scopeRef.current.sessionId } : {}),
477
+ });
478
+ exit();
479
+ }, [exit, onExitSummary]);
407
480
  /** Durable active state is not permission to spend turns after a restart. */
408
481
  const [goalActivation] = useState(() => new SessionGoalActivation());
409
482
  const goalDriveInFlightRef = useRef(false);
@@ -1085,8 +1158,8 @@ export function App({ ctx: initialCtx }) {
1085
1158
  // is idempotent, so a repeated render reaches the same answer.
1086
1159
  const window = liveWindow({
1087
1160
  messages: finalized,
1088
- rows: process.stdout.rows,
1089
- columns: process.stdout.columns,
1161
+ rows: stdout.rows,
1162
+ columns: stdout.columns,
1090
1163
  furnitureRows: LIVE_FURNITURE_ROWS,
1091
1164
  settled: settledRef.current,
1092
1165
  raw: rawOutput,
@@ -1096,14 +1169,28 @@ export function App({ ctx: initialCtx }) {
1096
1169
  // the answer is knowable. `liveRows` is the furniture beneath the transcript
1097
1170
  // PLUS the live window above it — the window is part of the live region, and
1098
1171
  // leaving it out would have the spacer padding room that is already taken.
1099
- const spacerRows = phase === 'ready'
1172
+ const spacerCandidate = phase === 'ready'
1100
1173
  ? bottomSpacerRows({
1101
- rows: process.stdout.rows,
1102
- columns: process.stdout.columns,
1174
+ rows: stdout.rows,
1175
+ columns: stdout.columns,
1103
1176
  transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
1104
1177
  liveRows: LIVE_FURNITURE_ROWS + window.rows,
1105
1178
  })
1106
1179
  : 0;
1180
+ const spacerLayout = {
1181
+ rows: stdout.rows,
1182
+ columns: stdout.columns,
1183
+ raw: rawOutput,
1184
+ messageCount: finalized.length,
1185
+ tail: finalized
1186
+ .slice(-MAX_LIVE_ROWS)
1187
+ .map(({ id, content, meta, detail }) => ({ id, content, meta, detail })),
1188
+ };
1189
+ const spacerRows = phase === 'ready' && sameSpacerLayout(spacerLayoutRef.current, spacerLayout)
1190
+ ? spacerLayoutRef.current.spacerRows
1191
+ : spacerCandidate;
1192
+ spacerLayoutRef.current =
1193
+ phase === 'ready' ? { ...spacerLayout, spacerRows } : null;
1107
1194
  // One merged vocabulary for the session: this host's own commands plus
1108
1195
  // whatever the kernel's registry reports. Built here so `/help`, the
1109
1196
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1216,6 +1303,8 @@ export function App({ ctx: initialCtx }) {
1216
1303
  goalActivation.disarm(activeSessionId);
1217
1304
  wakeGoalDriver();
1218
1305
  activeTurnTokenRef.current = null;
1306
+ activeTurnInboxRef.current = null;
1307
+ setPendingSteers([]);
1219
1308
  // Dropped now so a second interrupt does not re-abort, and the queue with
1220
1309
  // it: interrupting means stop, not "run the next one".
1221
1310
  abortRef.current = null;
@@ -1645,6 +1734,28 @@ export function App({ ctx: initialCtx }) {
1645
1734
  setActiveTools(activeToolsRef.current);
1646
1735
  break;
1647
1736
  }
1737
+ case 'tool-progress': {
1738
+ const running = activeToolsRef.current;
1739
+ const index = running.findIndex((tool) => tool.id === event.toolUseId);
1740
+ // A terminal event may already have removed this call. Never recreate
1741
+ // a live row from late diagnostic state.
1742
+ if (index < 0)
1743
+ break;
1744
+ const current = running[index];
1745
+ const { fraction: _fraction, ...withoutFraction } = current;
1746
+ const updated = {
1747
+ ...withoutFraction,
1748
+ progress: event.message,
1749
+ ...(event.fraction !== undefined ? { fraction: event.fraction } : {}),
1750
+ };
1751
+ activeToolsRef.current = [
1752
+ ...running.slice(0, index),
1753
+ updated,
1754
+ ...running.slice(index + 1),
1755
+ ];
1756
+ setActiveTools(activeToolsRef.current);
1757
+ break;
1758
+ }
1648
1759
  case 'tool-end': {
1649
1760
  const running = activeToolsRef.current;
1650
1761
  // Match strictly by toolUseId. Never fall back to "the first
@@ -1794,16 +1905,11 @@ export function App({ ctx: initialCtx }) {
1794
1905
  : undefined);
1795
1906
  const priorForSdk = [...historyBeforeTurn, userMessage];
1796
1907
  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
1908
  if (goalRound) {
1803
1909
  pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
1804
1910
  }
1805
1911
  else {
1806
- pushMessage('user', text, false, undefined, undefined, undefined, metaParts.length > 0 ? metaParts.join(' · ') : undefined);
1912
+ pushMessage('user', text, false, undefined, undefined, undefined, humanPromptMeta(attached.length, attachments));
1807
1913
  }
1808
1914
  // The model interleaves text → tool → text across iterations; `applyEvent`
1809
1915
  // renders each one in order.
@@ -1845,6 +1951,51 @@ export function App({ ctx: initialCtx }) {
1845
1951
  const turnGeneration = conversationGenRef.current;
1846
1952
  unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
1847
1953
  const stillHere = () => conversationGenRef.current === turnGeneration;
1954
+ let inboxOpen = true;
1955
+ const inboxEntries = [];
1956
+ const inbox = {
1957
+ accept(input) {
1958
+ if (!inboxOpen ||
1959
+ ac.signal.aborted ||
1960
+ !stillHere() ||
1961
+ activeTurnTokenRef.current !== turnToken ||
1962
+ activeTurnInboxRef.current !== inbox)
1963
+ return false;
1964
+ inboxEntries.push(input);
1965
+ setPendingSteers([...inboxEntries]);
1966
+ return true;
1967
+ },
1968
+ drain() {
1969
+ if (!inboxOpen ||
1970
+ ac.signal.aborted ||
1971
+ activeTurnTokenRef.current !== turnToken ||
1972
+ activeTurnInboxRef.current !== inbox ||
1973
+ inboxEntries.length === 0)
1974
+ return [];
1975
+ const delivered = inboxEntries.splice(0, inboxEntries.length);
1976
+ // This callback runs at the kernel's provider-valid boundary: every
1977
+ // delta from the prior answer has already crossed the event stream.
1978
+ // Commit the user rows here, not at keypress time, so the transcript
1979
+ // cannot place a steer inside an assistant message that is still live.
1980
+ if (stillHere()) {
1981
+ flushStream(st);
1982
+ if (st.assistantId) {
1983
+ finalizeMessage(st.assistantId);
1984
+ st.assistantId = null;
1985
+ }
1986
+ for (const input of delivered) {
1987
+ pushMessage('user', input.prompt.text, false, undefined, undefined, undefined, humanPromptMeta(input.attachedFiles, input.prompt.attachments, true));
1988
+ }
1989
+ setPendingSteers([]);
1990
+ }
1991
+ return delivered.map((input) => input.message);
1992
+ },
1993
+ close() {
1994
+ inboxOpen = false;
1995
+ return inboxEntries.splice(0, inboxEntries.length);
1996
+ },
1997
+ };
1998
+ activeTurnInboxRef.current = inbox;
1848
1999
  const turnPermissionMode = permissionModeRef.current;
1849
2000
  const turnReasoningEffort = reasoningEffortRef.current;
1850
2001
  // Always carry the guarded callback. `auto` and `strict` decide before
@@ -1908,6 +2059,7 @@ export function App({ ctx: initialCtx }) {
1908
2059
  ...(goalRound ? { goalRound } : {}),
1909
2060
  // The mode above decides whether this callback is consulted.
1910
2061
  onPermission: askPermission,
2062
+ inboundMessages: () => inbox.drain(),
1911
2063
  extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
1912
2064
  onConversationMessages: (messages) => {
1913
2065
  // State-only: opaque reasoning/signatures must reach the next
@@ -1960,6 +2112,12 @@ export function App({ ctx: initialCtx }) {
1960
2112
  }
1961
2113
  }
1962
2114
  finally {
2115
+ const undeliveredLiveInput = inbox.close();
2116
+ const ownsInbox = activeTurnInboxRef.current === inbox;
2117
+ if (ownsInbox) {
2118
+ activeTurnInboxRef.current = null;
2119
+ setPendingSteers([]);
2120
+ }
1963
2121
  // Prefer the kernel's settled conversation projection: it retains opaque
1964
2122
  // reasoning, citations and complete tool turns that the visible delta
1965
2123
  // stream cannot reconstruct. A fake/legacy session that does not publish
@@ -1984,6 +2142,9 @@ export function App({ ctx: initialCtx }) {
1984
2142
  // clear the state of whatever has started since.
1985
2143
  if (stillHere()) {
1986
2144
  const ownsTurn = activeTurnTokenRef.current === turnToken;
2145
+ if (ownsInbox && undeliveredLiveInput.length > 0) {
2146
+ replaceQueued(mergeUndeliveredLiveInput(queuedRef.current, undeliveredLiveInput));
2147
+ }
1987
2148
  const shouldPauseQueued = ownsTurn &&
1988
2149
  abnormalTerminal?.token === turnToken &&
1989
2150
  abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
@@ -2087,12 +2248,13 @@ export function App({ ctx: initialCtx }) {
2087
2248
  goalActivation,
2088
2249
  onPermission,
2089
2250
  pushMessage,
2251
+ replaceQueued,
2090
2252
  sendTerminalNotification,
2091
2253
  session,
2092
2254
  setQueuePause,
2093
2255
  wakeGoalDriver,
2094
2256
  ]);
2095
- const handleSubmit = useCallback((value, attachments) => {
2257
+ const handleSubmit = useCallback((value, attachments, mode = 'submit') => {
2096
2258
  if (goalCommandInFlightRef.current) {
2097
2259
  pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
2098
2260
  return;
@@ -2130,7 +2292,7 @@ export function App({ ctx: initialCtx }) {
2130
2292
  void startFreshConversation(slash.clearScreen);
2131
2293
  return;
2132
2294
  case 'exit':
2133
- exit();
2295
+ exitWithSummary();
2134
2296
  return;
2135
2297
  case 'repick':
2136
2298
  // Opened as a choice, not as a repair. A launch-time refusal
@@ -2671,11 +2833,25 @@ export function App({ ctx: initialCtx }) {
2671
2833
  // Keep the attachment array beside its text. Reconstructing a prompt from
2672
2834
  // the transcript later cannot recover bytes whose composer chip is gone.
2673
2835
  advanceQueueContinuation();
2674
- enqueueQueued({
2836
+ const prompt = {
2675
2837
  kind: 'human',
2676
2838
  text: outgoing,
2677
2839
  ...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
2678
- });
2840
+ };
2841
+ if (mode === 'submit') {
2842
+ const inbox = activeTurnInboxRef.current;
2843
+ if (inbox) {
2844
+ const expanded = expandFileMentions(outgoing, ctx.cwd);
2845
+ if (inbox.accept({
2846
+ prompt,
2847
+ message: createUserMessage(expanded.sendText, prompt.attachments),
2848
+ attachedFiles: expanded.attached.length,
2849
+ queueBoundary: queuedRef.current.length,
2850
+ }))
2851
+ return;
2852
+ }
2853
+ }
2854
+ enqueueQueued(prompt);
2679
2855
  }, [
2680
2856
  activeSkills,
2681
2857
  advanceQueueContinuation,
@@ -2684,7 +2860,7 @@ export function App({ ctx: initialCtx }) {
2684
2860
  ctx.cwd,
2685
2861
  doResume,
2686
2862
  enqueueQueued,
2687
- exit,
2863
+ exitWithSummary,
2688
2864
  hasUnsettledTurn,
2689
2865
  nextId,
2690
2866
  pushMessage,
@@ -2989,8 +3165,8 @@ export function App({ ctx: initialCtx }) {
2989
3165
  setPhase('ready');
2990
3166
  return;
2991
3167
  }
2992
- exit();
2993
- }, [session, exit]);
3168
+ exitWithSummary();
3169
+ }, [session, exitWithSummary]);
2994
3170
  useInput((input, key) => {
2995
3171
  // The provider picker owns its own keyboard — arrows, digits, enter,
2996
3172
  // esc and the typed-key screen are all its business. Ctrl+C is not: it
@@ -3006,7 +3182,7 @@ export function App({ ctx: initialCtx }) {
3006
3182
  // and the second exits", with nothing on screen naming either.
3007
3183
  if (phase === 'picker') {
3008
3184
  if (key.ctrl && input === 'c')
3009
- exit();
3185
+ exitWithSummary();
3010
3186
  return;
3011
3187
  }
3012
3188
  // Previous-prompt picker owns the keyboard. Esc keeps stepping toward
@@ -3064,7 +3240,7 @@ export function App({ ctx: initialCtx }) {
3064
3240
  // and making the escape hatch hesitate on the program's first screen
3065
3241
  // would read as a hang.
3066
3242
  if (ch === 'n' || key.escape || (key.ctrl && input === 'c')) {
3067
- exit();
3243
+ exitWithSummary();
3068
3244
  return;
3069
3245
  }
3070
3246
  // Enter is deliberately NOT trust, for the reason it is not approval
@@ -3114,7 +3290,14 @@ export function App({ ctx: initialCtx }) {
3114
3290
  // request and above the ordinary composer. Their values are a captured
3115
3291
  // menu, so a provider/session change cannot silently reinterpret Enter.
3116
3292
  if (choicePickerRef.current) {
3117
- const options = choicePickerRef.current.options;
3293
+ const picker = choicePickerRef.current;
3294
+ // Composer publishes a chooser while handling Return. App receives the
3295
+ // same input dispatch (and key repeat can arrive before the commit), so
3296
+ // the synchronous ref is an ownership fence but not yet an actionable
3297
+ // menu. Only the exact object React committed may consume a choice.
3298
+ if (choicePickerCommittedRef.current !== picker)
3299
+ return;
3300
+ const options = picker.options;
3118
3301
  if (key.escape || (key.ctrl && input === 'c')) {
3119
3302
  setChoicePicker(null);
3120
3303
  return;
@@ -3225,7 +3408,7 @@ export function App({ ctx: initialCtx }) {
3225
3408
  return;
3226
3409
  }
3227
3410
  if (exitArmedRef.current) {
3228
- exit();
3411
+ exitWithSummary();
3229
3412
  return;
3230
3413
  }
3231
3414
  exitArmedRef.current = true;
@@ -3242,11 +3425,14 @@ export function App({ ctx: initialCtx }) {
3242
3425
  // Background is left natural — we inherit the terminal's own background
3243
3426
  // and only theme the foreground. Forcing
3244
3427
  // 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' &&
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' &&
3246
3429
  phase === 'ready' &&
3247
3430
  conversationMutation === null &&
3248
3431
  choicePicker === null &&
3249
- copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [queued.length > 0 &&
3432
+ copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
3433
+ permission === null &&
3434
+ choicePicker === null &&
3435
+ 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
3436
  permission === null &&
3251
3437
  choicePicker === null &&
3252
3438
  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
@@ -3336,7 +3522,7 @@ canReturn = false) {
3336
3522
  if (state === 'awaiting-permission')
3337
3523
  return 'y approve · n / esc reject · a approve all';
3338
3524
  if (state !== 'idle')
3339
- return 'agent is working — esc to interrupt';
3525
+ return 'enter steer · tab queue · esc interrupt';
3340
3526
  // The composer already points to /help. Keeping the complete key legend in
3341
3527
  // every idle frame made ordinary conversation read like a permanent setup
3342
3528
  // screen; only state-specific actions belong in the footer.