@namzu/cli 14.2.0 → 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 (88) hide show
  1. package/README.md +31 -7
  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/login.d.ts +21 -3
  6. package/dist/commands/login.d.ts.map +1 -1
  7. package/dist/commands/login.js +33 -21
  8. package/dist/commands/login.js.map +1 -1
  9. package/dist/commands/prior-messages.d.ts.map +1 -1
  10. package/dist/commands/prior-messages.js +13 -4
  11. package/dist/commands/prior-messages.js.map +1 -1
  12. package/dist/commands/upgrade.d.ts +25 -0
  13. package/dist/commands/upgrade.d.ts.map +1 -0
  14. package/dist/commands/upgrade.js +182 -0
  15. package/dist/commands/upgrade.js.map +1 -0
  16. package/dist/context/runtime-message.d.ts +4 -0
  17. package/dist/context/runtime-message.d.ts.map +1 -0
  18. package/dist/context/runtime-message.js +20 -0
  19. package/dist/context/runtime-message.js.map +1 -0
  20. package/dist/integrations/providers/identity.d.ts +16 -21
  21. package/dist/integrations/providers/identity.d.ts.map +1 -1
  22. package/dist/integrations/providers/identity.js +16 -21
  23. package/dist/integrations/providers/identity.js.map +1 -1
  24. package/dist/integrations/providers/index.d.ts +1 -1
  25. package/dist/integrations/providers/index.d.ts.map +1 -1
  26. package/dist/integrations/providers/index.js +1 -1
  27. package/dist/integrations/providers/index.js.map +1 -1
  28. package/dist/integrations/providers/subscription-login.d.ts +7 -41
  29. package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
  30. package/dist/integrations/providers/subscription-login.js +16 -143
  31. package/dist/integrations/providers/subscription-login.js.map +1 -1
  32. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  33. package/dist/integrations/sessions/transcript-export.js +10 -1
  34. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  35. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -1
  36. package/dist/integrations/sessions/turn-evidence.js +9 -1
  37. package/dist/integrations/sessions/turn-evidence.js.map +1 -1
  38. package/dist/integrations/updates.d.ts +7 -1
  39. package/dist/integrations/updates.d.ts.map +1 -1
  40. package/dist/integrations/updates.js +59 -21
  41. package/dist/integrations/updates.js.map +1 -1
  42. package/dist/tui/App.d.ts +3 -1
  43. package/dist/tui/App.d.ts.map +1 -1
  44. package/dist/tui/App.js +287 -96
  45. package/dist/tui/App.js.map +1 -1
  46. package/dist/tui/Composer.d.ts +15 -5
  47. package/dist/tui/Composer.d.ts.map +1 -1
  48. package/dist/tui/Composer.js +73 -8
  49. package/dist/tui/Composer.js.map +1 -1
  50. package/dist/tui/LiveActivity.d.ts +3 -0
  51. package/dist/tui/LiveActivity.d.ts.map +1 -1
  52. package/dist/tui/LiveActivity.js +4 -1
  53. package/dist/tui/LiveActivity.js.map +1 -1
  54. package/dist/tui/Picker.d.ts +4 -2
  55. package/dist/tui/Picker.d.ts.map +1 -1
  56. package/dist/tui/Picker.js +136 -16
  57. package/dist/tui/Picker.js.map +1 -1
  58. package/dist/tui/StatusBar.d.ts +1 -1
  59. package/dist/tui/StatusBar.d.ts.map +1 -1
  60. package/dist/tui/StatusBar.js +8 -5
  61. package/dist/tui/StatusBar.js.map +1 -1
  62. package/dist/tui/Transcript.d.ts +3 -1
  63. package/dist/tui/Transcript.d.ts.map +1 -1
  64. package/dist/tui/Transcript.js +2 -2
  65. package/dist/tui/Transcript.js.map +1 -1
  66. package/dist/tui/agent.d.ts +15 -0
  67. package/dist/tui/agent.d.ts.map +1 -1
  68. package/dist/tui/agent.js +17 -0
  69. package/dist/tui/agent.js.map +1 -1
  70. package/dist/tui/exit-summary.d.ts +6 -0
  71. package/dist/tui/exit-summary.d.ts.map +1 -0
  72. package/dist/tui/exit-summary.js +8 -0
  73. package/dist/tui/exit-summary.js.map +1 -0
  74. package/dist/tui/index.d.ts.map +1 -1
  75. package/dist/tui/index.js +15 -6
  76. package/dist/tui/index.js.map +1 -1
  77. package/dist/tui/log-pane.d.ts +13 -8
  78. package/dist/tui/log-pane.d.ts.map +1 -1
  79. package/dist/tui/log-pane.js +23 -29
  80. package/dist/tui/log-pane.js.map +1 -1
  81. package/dist/tui/login-prompt.d.ts +0 -1
  82. package/dist/tui/login-prompt.d.ts.map +1 -1
  83. package/dist/tui/login-prompt.js +1 -6
  84. package/dist/tui/login-prompt.js.map +1 -1
  85. package/dist/tui/open-browser.d.ts.map +1 -1
  86. package/dist/tui/open-browser.js +27 -9
  87. package/dist/tui/open-browser.js.map +1 -1
  88. package/package.json +7 -7
package/dist/tui/App.js CHANGED
@@ -17,9 +17,10 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
17
17
  import { useCallback, useEffect, useRef, useState } from 'react';
18
18
  import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
19
19
  import { visibleProjectInstructionPath } from '../context/project-path.js';
20
+ import { runtimeContextLabel } from '../context/runtime-message.js';
20
21
  import { writeClipboardText } from '../integrations/clipboard/text.js';
21
22
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
22
- import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
23
+ import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, parsePastedInput, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
23
24
  import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
24
25
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
25
26
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
@@ -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);
@@ -836,15 +909,14 @@ export function App({ ctx: initialCtx }) {
836
909
  * nobody can see. Everything this path says goes into `pickerNotice`, which
837
910
  * the picker renders.
838
911
  *
839
- * It uses the browser route only. There is no paste input on this screen,
840
- * and rather than invent one — a second text field beside the credential
841
- * one, on the screen an operator reaches when they are already stuck — the
842
- * no-browser case is handed to `namzu login`, which reads the pasted
843
- * address from standard input and exists for exactly that machine.
912
+ * The registered browser callback shows a code rather than returning to a
913
+ * local listener. The picker therefore owns the matching paste field; sending a
914
+ * first-run operator to another terminal would make the sign-in technically
915
+ * started here but impossible to finish on the screen that started it.
844
916
  */
845
917
  const startLoginFromPicker = useCallback(async (provider, signal) => {
846
918
  if (signal.aborted)
847
- return;
919
+ return 'failed';
848
920
  cancelPendingLogin();
849
921
  if (provider === 'codex') {
850
922
  let start;
@@ -855,11 +927,11 @@ export function App({ ctx: initialCtx }) {
855
927
  if (!signal.aborted) {
856
928
  setPickerNotice(`Could not start Codex sign-in: ${error instanceof Error ? error.message : String(error)}`);
857
929
  }
858
- return;
930
+ return 'failed';
859
931
  }
860
932
  if (signal.aborted) {
861
933
  start.cancel();
862
- return;
934
+ return 'failed';
863
935
  }
864
936
  codexLoginRef.current = start;
865
937
  setPickerNotice(describeCodexDeviceLoginStart({
@@ -869,7 +941,7 @@ export function App({ ctx: initialCtx }) {
869
941
  }));
870
942
  const outcome = await start.waitForCompletion();
871
943
  if (codexLoginRef.current !== start || signal.aborted)
872
- return;
944
+ return 'failed';
873
945
  codexLoginRef.current = null;
874
946
  start.cancel();
875
947
  setPickerNotice(describeLoginOutcome(outcome));
@@ -877,7 +949,7 @@ export function App({ ctx: initialCtx }) {
877
949
  setPickerInitialView('providers');
878
950
  await runProbeRef.current?.(signal);
879
951
  }
880
- return;
952
+ return 'finished';
881
953
  }
882
954
  let start;
883
955
  try {
@@ -885,13 +957,13 @@ export function App({ ctx: initialCtx }) {
885
957
  }
886
958
  catch (err) {
887
959
  if (signal.aborted)
888
- return;
960
+ return 'failed';
889
961
  setPickerNotice(`Could not start a sign-in: ${err instanceof Error ? err.message : String(err)}`);
890
- return;
962
+ return 'failed';
891
963
  }
892
964
  if (signal.aborted) {
893
965
  start.cancel();
894
- return;
966
+ return 'failed';
895
967
  }
896
968
  loginRef.current = start;
897
969
  const cancelForPicker = () => {
@@ -906,36 +978,39 @@ export function App({ ctx: initialCtx }) {
906
978
  signal.addEventListener('abort', cancelForPicker, { once: true });
907
979
  if (signal.aborted) {
908
980
  cancelForPicker();
909
- return;
981
+ return 'failed';
910
982
  }
911
983
  setPickerNotice(describeLoginStart({
912
984
  url: start.url,
913
- loopback: start.loopback,
914
985
  browserOpened: openInBrowser(start.url),
915
- // Named for THIS screen. Telling someone at the picker to type a
916
- // slash command is what made the feature unreachable; telling them
917
- // to run a command in another terminal is something they can do
918
- // from where they are sitting.
919
- completionHint: 'run "namzu login" in a terminal and paste it there',
986
+ completionHint: 'paste it below and press enter',
920
987
  }));
921
- const waiting = start.waitForCallback();
922
- if (!waiting)
923
- return;
924
- const outcome = await waiting;
925
- if (loginRef.current !== start || signal.aborted)
926
- return;
988
+ return 'awaiting-input';
989
+ }, [cancelPendingLogin]);
990
+ const finishLoginFromPicker = useCallback(async (pasted, signal) => {
991
+ const pending = loginRef.current;
992
+ if (!pending) {
993
+ setPickerNotice('There is no Claude sign-in waiting for this code. Start it again.');
994
+ return 'finished';
995
+ }
996
+ if (!parsePastedInput(pasted).code) {
997
+ setPickerNotice('That does not contain an authorization code. Paste the whole finished address, or the code Claude showed.');
998
+ return 'retry';
999
+ }
1000
+ const outcome = await pending.completeWithPastedCode(pasted);
1001
+ if (loginRef.current !== pending || signal.aborted)
1002
+ return 'finished';
927
1003
  loginRef.current = null;
928
- cleanup();
1004
+ loginAbortCleanupRef.current?.();
929
1005
  loginAbortCleanupRef.current = null;
930
- start.cancel();
931
- if (!outcome.ok && outcome.reason.includes('cancelled'))
932
- return;
1006
+ pending.cancel();
933
1007
  setPickerNotice(describeLoginOutcome(outcome));
934
1008
  if (outcome.ok) {
935
1009
  setPickerInitialView('providers');
936
1010
  await runProbeRef.current?.(signal);
937
1011
  }
938
- }, [cancelPendingLogin]);
1012
+ return 'finished';
1013
+ }, []);
939
1014
  const runProbe = useCallback(async (signal) => {
940
1015
  try {
941
1016
  if (signal?.aborted)
@@ -1083,8 +1158,8 @@ export function App({ ctx: initialCtx }) {
1083
1158
  // is idempotent, so a repeated render reaches the same answer.
1084
1159
  const window = liveWindow({
1085
1160
  messages: finalized,
1086
- rows: process.stdout.rows,
1087
- columns: process.stdout.columns,
1161
+ rows: stdout.rows,
1162
+ columns: stdout.columns,
1088
1163
  furnitureRows: LIVE_FURNITURE_ROWS,
1089
1164
  settled: settledRef.current,
1090
1165
  raw: rawOutput,
@@ -1094,14 +1169,28 @@ export function App({ ctx: initialCtx }) {
1094
1169
  // the answer is knowable. `liveRows` is the furniture beneath the transcript
1095
1170
  // PLUS the live window above it — the window is part of the live region, and
1096
1171
  // leaving it out would have the spacer padding room that is already taken.
1097
- const spacerRows = phase === 'ready'
1172
+ const spacerCandidate = phase === 'ready'
1098
1173
  ? bottomSpacerRows({
1099
- rows: process.stdout.rows,
1100
- columns: process.stdout.columns,
1174
+ rows: stdout.rows,
1175
+ columns: stdout.columns,
1101
1176
  transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
1102
1177
  liveRows: LIVE_FURNITURE_ROWS + window.rows,
1103
1178
  })
1104
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;
1105
1194
  // One merged vocabulary for the session: this host's own commands plus
1106
1195
  // whatever the kernel's registry reports. Built here so `/help`, the
1107
1196
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1214,6 +1303,8 @@ export function App({ ctx: initialCtx }) {
1214
1303
  goalActivation.disarm(activeSessionId);
1215
1304
  wakeGoalDriver();
1216
1305
  activeTurnTokenRef.current = null;
1306
+ activeTurnInboxRef.current = null;
1307
+ setPendingSteers([]);
1217
1308
  // Dropped now so a second interrupt does not re-abort, and the queue with
1218
1309
  // it: interrupting means stop, not "run the next one".
1219
1310
  abortRef.current = null;
@@ -1643,6 +1734,28 @@ export function App({ ctx: initialCtx }) {
1643
1734
  setActiveTools(activeToolsRef.current);
1644
1735
  break;
1645
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
+ }
1646
1759
  case 'tool-end': {
1647
1760
  const running = activeToolsRef.current;
1648
1761
  // Match strictly by toolUseId. Never fall back to "the first
@@ -1792,16 +1905,11 @@ export function App({ ctx: initialCtx }) {
1792
1905
  : undefined);
1793
1906
  const priorForSdk = [...historyBeforeTurn, userMessage];
1794
1907
  const runId = generateRunId();
1795
- const metaParts = [];
1796
- if (attached.length > 0)
1797
- metaParts.push(`${attached.length} file${attached.length > 1 ? 's' : ''} attached`);
1798
- if (attachments && attachments.length > 0)
1799
- metaParts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
1800
1908
  if (goalRound) {
1801
1909
  pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
1802
1910
  }
1803
1911
  else {
1804
- 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));
1805
1913
  }
1806
1914
  // The model interleaves text → tool → text across iterations; `applyEvent`
1807
1915
  // renders each one in order.
@@ -1843,6 +1951,51 @@ export function App({ ctx: initialCtx }) {
1843
1951
  const turnGeneration = conversationGenRef.current;
1844
1952
  unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
1845
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;
1846
1999
  const turnPermissionMode = permissionModeRef.current;
1847
2000
  const turnReasoningEffort = reasoningEffortRef.current;
1848
2001
  // Always carry the guarded callback. `auto` and `strict` decide before
@@ -1906,6 +2059,7 @@ export function App({ ctx: initialCtx }) {
1906
2059
  ...(goalRound ? { goalRound } : {}),
1907
2060
  // The mode above decides whether this callback is consulted.
1908
2061
  onPermission: askPermission,
2062
+ inboundMessages: () => inbox.drain(),
1909
2063
  extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
1910
2064
  onConversationMessages: (messages) => {
1911
2065
  // State-only: opaque reasoning/signatures must reach the next
@@ -1958,6 +2112,12 @@ export function App({ ctx: initialCtx }) {
1958
2112
  }
1959
2113
  }
1960
2114
  finally {
2115
+ const undeliveredLiveInput = inbox.close();
2116
+ const ownsInbox = activeTurnInboxRef.current === inbox;
2117
+ if (ownsInbox) {
2118
+ activeTurnInboxRef.current = null;
2119
+ setPendingSteers([]);
2120
+ }
1961
2121
  // Prefer the kernel's settled conversation projection: it retains opaque
1962
2122
  // reasoning, citations and complete tool turns that the visible delta
1963
2123
  // stream cannot reconstruct. A fake/legacy session that does not publish
@@ -1982,6 +2142,9 @@ export function App({ ctx: initialCtx }) {
1982
2142
  // clear the state of whatever has started since.
1983
2143
  if (stillHere()) {
1984
2144
  const ownsTurn = activeTurnTokenRef.current === turnToken;
2145
+ if (ownsInbox && undeliveredLiveInput.length > 0) {
2146
+ replaceQueued(mergeUndeliveredLiveInput(queuedRef.current, undeliveredLiveInput));
2147
+ }
1985
2148
  const shouldPauseQueued = ownsTurn &&
1986
2149
  abnormalTerminal?.token === turnToken &&
1987
2150
  abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
@@ -2085,12 +2248,13 @@ export function App({ ctx: initialCtx }) {
2085
2248
  goalActivation,
2086
2249
  onPermission,
2087
2250
  pushMessage,
2251
+ replaceQueued,
2088
2252
  sendTerminalNotification,
2089
2253
  session,
2090
2254
  setQueuePause,
2091
2255
  wakeGoalDriver,
2092
2256
  ]);
2093
- const handleSubmit = useCallback((value, attachments) => {
2257
+ const handleSubmit = useCallback((value, attachments, mode = 'submit') => {
2094
2258
  if (goalCommandInFlightRef.current) {
2095
2259
  pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
2096
2260
  return;
@@ -2128,7 +2292,7 @@ export function App({ ctx: initialCtx }) {
2128
2292
  void startFreshConversation(slash.clearScreen);
2129
2293
  return;
2130
2294
  case 'exit':
2131
- exit();
2295
+ exitWithSummary();
2132
2296
  return;
2133
2297
  case 'repick':
2134
2298
  // Opened as a choice, not as a repair. A launch-time refusal
@@ -2669,11 +2833,25 @@ export function App({ ctx: initialCtx }) {
2669
2833
  // Keep the attachment array beside its text. Reconstructing a prompt from
2670
2834
  // the transcript later cannot recover bytes whose composer chip is gone.
2671
2835
  advanceQueueContinuation();
2672
- enqueueQueued({
2836
+ const prompt = {
2673
2837
  kind: 'human',
2674
2838
  text: outgoing,
2675
2839
  ...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
2676
- });
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);
2677
2855
  }, [
2678
2856
  activeSkills,
2679
2857
  advanceQueueContinuation,
@@ -2682,7 +2860,7 @@ export function App({ ctx: initialCtx }) {
2682
2860
  ctx.cwd,
2683
2861
  doResume,
2684
2862
  enqueueQueued,
2685
- exit,
2863
+ exitWithSummary,
2686
2864
  hasUnsettledTurn,
2687
2865
  nextId,
2688
2866
  pushMessage,
@@ -2987,8 +3165,8 @@ export function App({ ctx: initialCtx }) {
2987
3165
  setPhase('ready');
2988
3166
  return;
2989
3167
  }
2990
- exit();
2991
- }, [session, exit]);
3168
+ exitWithSummary();
3169
+ }, [session, exitWithSummary]);
2992
3170
  useInput((input, key) => {
2993
3171
  // The provider picker owns its own keyboard — arrows, digits, enter,
2994
3172
  // esc and the typed-key screen are all its business. Ctrl+C is not: it
@@ -3004,7 +3182,7 @@ export function App({ ctx: initialCtx }) {
3004
3182
  // and the second exits", with nothing on screen naming either.
3005
3183
  if (phase === 'picker') {
3006
3184
  if (key.ctrl && input === 'c')
3007
- exit();
3185
+ exitWithSummary();
3008
3186
  return;
3009
3187
  }
3010
3188
  // Previous-prompt picker owns the keyboard. Esc keeps stepping toward
@@ -3062,7 +3240,7 @@ export function App({ ctx: initialCtx }) {
3062
3240
  // and making the escape hatch hesitate on the program's first screen
3063
3241
  // would read as a hang.
3064
3242
  if (ch === 'n' || key.escape || (key.ctrl && input === 'c')) {
3065
- exit();
3243
+ exitWithSummary();
3066
3244
  return;
3067
3245
  }
3068
3246
  // Enter is deliberately NOT trust, for the reason it is not approval
@@ -3112,7 +3290,14 @@ export function App({ ctx: initialCtx }) {
3112
3290
  // request and above the ordinary composer. Their values are a captured
3113
3291
  // menu, so a provider/session change cannot silently reinterpret Enter.
3114
3292
  if (choicePickerRef.current) {
3115
- 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;
3116
3301
  if (key.escape || (key.ctrl && input === 'c')) {
3117
3302
  setChoicePicker(null);
3118
3303
  return;
@@ -3223,7 +3408,7 @@ export function App({ ctx: initialCtx }) {
3223
3408
  return;
3224
3409
  }
3225
3410
  if (exitArmedRef.current) {
3226
- exit();
3411
+ exitWithSummary();
3227
3412
  return;
3228
3413
  }
3229
3414
  exitArmedRef.current = true;
@@ -3240,46 +3425,49 @@ export function App({ ctx: initialCtx }) {
3240
3425
  // Background is left natural — we inherit the terminal's own background
3241
3426
  // and only theme the foreground. Forcing
3242
3427
  // a filled bg left mismatched patches around bordered areas, so we don't.
3243
- return (_jsxs(Box, { flexDirection: "column", children: [phase !== 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : null, _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: (provider, signal) => void startLoginFromPicker(provider, signal), 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' &&
3244
- phase === 'ready' &&
3245
- conversationMutation === null &&
3246
- choicePicker === null &&
3247
- copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [queued.length > 0 &&
3248
- permission === null &&
3249
- choicePicker === null &&
3250
- 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
3251
- ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
3252
- : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
3253
- state === 'awaiting-permission' ||
3254
- compacting ||
3255
- conversationMutation !== null, hidden: permission !== null || choicePicker !== null || copyPicker !== null,
3256
- // A turn is running, so Esc is the interrupt and not
3257
- // the composer's clear.
3258
- 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' &&
3259
- 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' &&
3429
+ phase === 'ready' &&
3260
3430
  conversationMutation === null &&
3261
- !compacting &&
3262
- permission === null &&
3263
3431
  choicePicker === null &&
3264
- copyPicker === null
3265
- ? goalStatusLabel(goalStatus)
3266
- : null, state: state, hint: conversationMutation === 'fork'
3267
- ? 'forking conversation — input is paused'
3268
- : conversationMutation === 'edit'
3269
- ? 'branching before prompt — input is paused'
3270
- : conversationMutation === 'new'
3271
- ? 'starting a fresh conversation — input is paused'
3272
- : compacting
3273
- ? 'compacting conversation — input is paused'
3274
- : permission
3275
- ? hintForPhase(phase, state, session?.hasProvider === true)
3276
- : choicePicker
3277
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
3278
- : copyPicker
3279
- ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
3280
- : goalStatus && phase === 'ready' && state === 'idle'
3281
- ? undefined
3282
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
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 &&
3436
+ permission === null &&
3437
+ choicePicker === null &&
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
3439
+ ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
3440
+ : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
3441
+ state === 'awaiting-permission' ||
3442
+ compacting ||
3443
+ conversationMutation !== null, hidden: permission !== null || choicePicker !== null || copyPicker !== null,
3444
+ // A turn is running, so Esc is the interrupt and not
3445
+ // 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' &&
3447
+ state === 'idle' &&
3448
+ conversationMutation === null &&
3449
+ !compacting &&
3450
+ permission === null &&
3451
+ choicePicker === null &&
3452
+ copyPicker === null
3453
+ ? goalStatusLabel(goalStatus)
3454
+ : null, state: state, hint: conversationMutation === 'fork'
3455
+ ? 'forking conversation — input is paused'
3456
+ : conversationMutation === 'edit'
3457
+ ? '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 }) })] }) }));
3283
3471
  }
3284
3472
  function Banner({ version, session, permissionMode, cwd, }) {
3285
3473
  const cols = process.stdout.columns ?? 80;
@@ -3334,8 +3522,11 @@ canReturn = false) {
3334
3522
  if (state === 'awaiting-permission')
3335
3523
  return 'y approve · n / esc reject · a approve all';
3336
3524
  if (state !== 'idle')
3337
- return 'agent is working — esc to interrupt';
3338
- return '/help · Esc×2 edit previous · @file / Ctrl+V attach · Ctrl+C ×2 to exit';
3525
+ return 'enter steer · tab queue · esc interrupt';
3526
+ // The composer already points to /help. Keeping the complete key legend in
3527
+ // every idle frame made ordinary conversation read like a permanent setup
3528
+ // screen; only state-specific actions belong in the footer.
3529
+ return '';
3339
3530
  }
3340
3531
  function goalStatusLabel(goal) {
3341
3532
  if (!goal)