@namzu/cli 14.0.0 → 14.2.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 (60) hide show
  1. package/README.md +18 -7
  2. package/dist/integrations/providers/discover.d.ts +18 -0
  3. package/dist/integrations/providers/discover.d.ts.map +1 -1
  4. package/dist/integrations/providers/discover.js +79 -37
  5. package/dist/integrations/providers/discover.js.map +1 -1
  6. package/dist/integrations/providers/harness-credentials.d.ts +44 -9
  7. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  8. package/dist/integrations/providers/harness-credentials.js +140 -11
  9. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  10. package/dist/integrations/providers/identity.d.ts +15 -14
  11. package/dist/integrations/providers/identity.d.ts.map +1 -1
  12. package/dist/integrations/providers/identity.js +15 -14
  13. package/dist/integrations/providers/identity.js.map +1 -1
  14. package/dist/integrations/providers/index.d.ts +1 -1
  15. package/dist/integrations/providers/index.d.ts.map +1 -1
  16. package/dist/integrations/providers/index.js +1 -1
  17. package/dist/integrations/providers/index.js.map +1 -1
  18. package/dist/integrations/providers/oauth.d.ts +8 -3
  19. package/dist/integrations/providers/oauth.d.ts.map +1 -1
  20. package/dist/integrations/providers/oauth.js +37 -11
  21. package/dist/integrations/providers/oauth.js.map +1 -1
  22. package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
  23. package/dist/integrations/providers/subscription-login.js +12 -8
  24. package/dist/integrations/providers/subscription-login.js.map +1 -1
  25. package/dist/tui/App.d.ts +5 -5
  26. package/dist/tui/App.d.ts.map +1 -1
  27. package/dist/tui/App.js +437 -63
  28. package/dist/tui/App.js.map +1 -1
  29. package/dist/tui/ChoicePicker.d.ts +14 -0
  30. package/dist/tui/ChoicePicker.d.ts.map +1 -0
  31. package/dist/tui/ChoicePicker.js +15 -0
  32. package/dist/tui/ChoicePicker.js.map +1 -0
  33. package/dist/tui/CopyPicker.d.ts +8 -0
  34. package/dist/tui/CopyPicker.d.ts.map +1 -0
  35. package/dist/tui/CopyPicker.js +20 -0
  36. package/dist/tui/CopyPicker.js.map +1 -0
  37. package/dist/tui/Picker.d.ts +8 -1
  38. package/dist/tui/Picker.d.ts.map +1 -1
  39. package/dist/tui/Picker.js +24 -11
  40. package/dist/tui/Picker.js.map +1 -1
  41. package/dist/tui/StatusBar.d.ts +23 -39
  42. package/dist/tui/StatusBar.d.ts.map +1 -1
  43. package/dist/tui/StatusBar.js +67 -105
  44. package/dist/tui/StatusBar.js.map +1 -1
  45. package/dist/tui/agent.d.ts +2 -5
  46. package/dist/tui/agent.d.ts.map +1 -1
  47. package/dist/tui/agent.js +15 -9
  48. package/dist/tui/agent.js.map +1 -1
  49. package/dist/tui/copy-targets.d.ts +23 -0
  50. package/dist/tui/copy-targets.d.ts.map +1 -0
  51. package/dist/tui/copy-targets.js +189 -0
  52. package/dist/tui/copy-targets.js.map +1 -0
  53. package/dist/tui/model-choices.d.ts.map +1 -1
  54. package/dist/tui/model-choices.js +6 -1
  55. package/dist/tui/model-choices.js.map +1 -1
  56. package/dist/tui/slashCommands.d.ts +9 -1
  57. package/dist/tui/slashCommands.d.ts.map +1 -1
  58. package/dist/tui/slashCommands.js +21 -16
  59. package/dist/tui/slashCommands.js.map +1 -1
  60. package/package.json +5 -5
package/dist/tui/App.js CHANGED
@@ -5,11 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  *
6
6
  * Session lifecycle:
7
7
  * 1. Mount → probeAgentSession() (readPreferences + discoverProviders).
8
- * 2. If a v2 preferences file exists → createAgentSession(prefs, detected) → ready.
9
- * 3. If preferences missing OR v1 (legacy) → show <Picker/>.
10
- * After picker submit, writePreferences + hydrate session.
11
- * 4. If discovery returned zero providers → show <Picker/> in
12
- * empty-state mode (explains where to put credentials).
8
+ * 2. If a current preferences file exists → createAgentSession(prefs, detected) → ready.
9
+ * 3. With no preferences, reuse one signed-in installed-harness subscription
10
+ * directly, or ask between available subscriptions when there is a choice.
11
+ * 4. Otherwise show <Picker/>. An explicit picker choice is persisted;
12
+ * automatic reuse is not mistaken for a durable operator preference.
13
13
  */
14
14
  import { join, relative } from 'node:path';
15
15
  import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
@@ -19,7 +19,7 @@ import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/
19
19
  import { visibleProjectInstructionPath } from '../context/project-path.js';
20
20
  import { writeClipboardText } from '../integrations/clipboard/text.js';
21
21
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
22
- import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, writePreferences, } from '../integrations/providers/index.js';
22
+ import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
23
23
  import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
24
24
  import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
25
25
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
@@ -27,7 +27,9 @@ import { checkUpdates } from '../integrations/updates.js';
27
27
  import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.js';
28
28
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
29
29
  import { discoverUserCommands } from '../user-commands/store.js';
30
+ import { ChoicePicker } from './ChoicePicker.js';
30
31
  import { Composer } from './Composer.js';
32
+ import { CopyPicker } from './CopyPicker.js';
31
33
  import { EditPromptPicker } from './EditPromptPicker.js';
32
34
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
33
35
  import { PermissionOverlay } from './PermissionOverlay.js';
@@ -41,6 +43,7 @@ import { bottomSpacerRows } from './bottom-spacer.js';
41
43
  import { keepRecentRows } from './compact-transcript.js';
42
44
  import { approvalIsDeliberate } from './consent-timing.js';
43
45
  import { planTurnPublication } from './conversation-history.js';
46
+ import { copyTargetsForResponse } from './copy-targets.js';
44
47
  import { editablePrompts } from './edit-prompts.js';
45
48
  import { liveWindow, transcriptLines } from './live-window.js';
46
49
  import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
@@ -215,6 +218,10 @@ export function App({ ctx: initialCtx }) {
215
218
  const [keyEntryFor, setKeyEntryFor] = useState(null);
216
219
  /** Which decision owns the next picker mount. */
217
220
  const [pickerInitialView, setPickerInitialView] = useState('providers');
221
+ /** A narrowed first-run roster; null keeps the complete discovered list. */
222
+ const [pickerDetected, setPickerDetected] = useState(null);
223
+ /** Whether Enter accepts the provider directly or continues to model choice. */
224
+ const [pickerSelectionKind, setPickerSelectionKind] = useState('provider-and-model');
218
225
  /** The chain read from disk, held while the picker repairs its credential. */
219
226
  const savedPrefsRef = useRef(null);
220
227
  /**
@@ -314,6 +321,22 @@ export function App({ ctx: initialCtx }) {
314
321
  const [editList, setEditList] = useState([]);
315
322
  const [selectedEdit, setSelectedEdit] = useState(0);
316
323
  const editCommittedRef = useRef(false);
324
+ /** `/copy` owns this exact response snapshot until selection or cancellation. */
325
+ const [copyPicker, setCopyPickerState] = useState(null);
326
+ const copyPickerRef = useRef(null);
327
+ const [selectedCopy, setSelectedCopy] = useState(0);
328
+ const setCopyPicker = useCallback((next) => {
329
+ copyPickerRef.current = next;
330
+ setCopyPickerState(next);
331
+ }, []);
332
+ /** Finite slash-command choice owned synchronously until apply or cancel. */
333
+ const [choicePicker, setChoicePickerState] = useState(null);
334
+ const choicePickerRef = useRef(null);
335
+ const [selectedChoice, setSelectedChoice] = useState(0);
336
+ const setChoicePicker = useCallback((next) => {
337
+ choicePickerRef.current = next;
338
+ setChoicePickerState(next);
339
+ }, []);
317
340
  const [composerDraft, setComposerDraft] = useState(null);
318
341
  const composerDraftTokenRef = useRef(0);
319
342
  const exitArmedRef = useRef(false);
@@ -332,6 +355,9 @@ export function App({ ctx: initialCtx }) {
332
355
  const loginRef = useRef(null);
333
356
  const codexLoginRef = useRef(null);
334
357
  const loginAbortCleanupRef = useRef(null);
358
+ /** Revokes automatic first-run construction when this App no longer exists. */
359
+ const [appLifetime] = useState(() => new AbortController());
360
+ useEffect(() => () => appLifetime.abort(new Error('The Namzu TUI was closed.')), [appLifetime]);
335
361
  const cancelPendingLogin = useCallback(() => {
336
362
  loginAbortCleanupRef.current?.();
337
363
  loginAbortCleanupRef.current = null;
@@ -382,6 +408,7 @@ export function App({ ctx: initialCtx }) {
382
408
  const [goalActivation] = useState(() => new SessionGoalActivation());
383
409
  const goalDriveInFlightRef = useRef(false);
384
410
  const [goalDriveVersion, setGoalDriveVersion] = useState(0);
411
+ const [goalStatus, setGoalStatus] = useState(null);
385
412
  const wakeGoalDriver = useCallback(() => setGoalDriveVersion((version) => version + 1), []);
386
413
  /**
387
414
  * Conversation writes in the order the operator produced them.
@@ -500,6 +527,106 @@ export function App({ ctx: initialCtx }) {
500
527
  ]);
501
528
  return id;
502
529
  }, [nextId]);
530
+ const applyPermissionMode = useCallback((mode) => {
531
+ if (!session?.hasProvider) {
532
+ pushMessage('system', 'No active session — pick a provider before changing permissions.');
533
+ return;
534
+ }
535
+ if (state !== 'idle' ||
536
+ abortRef.current !== null ||
537
+ hasUnsettledTurn() ||
538
+ queuedRef.current.length > 0 ||
539
+ permissionResolveRef.current !== null ||
540
+ compactingRef.current) {
541
+ pushMessage('system', 'Permission mode was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
542
+ return;
543
+ }
544
+ if (!session.resetApprovalLatch) {
545
+ pushMessage('system', 'Permission mode was not changed: this embedded session cannot revoke an earlier "approve all" choice. Reconnect it before changing modes.');
546
+ return;
547
+ }
548
+ session.resetApprovalLatch();
549
+ permissionModeRef.current = mode;
550
+ permissionModeSourceRef.current = 'session';
551
+ setPermissionModeState(mode);
552
+ pushMessage('system', `Permission mode changed to ${mode}. Any earlier "approve all" choice was revoked. Declarative deny rules and the built-in safety gate still take precedence.`);
553
+ }, [hasUnsettledTurn, pushMessage, session, state]);
554
+ const applyReasoningEffort = useCallback((effort) => {
555
+ if (!session?.hasProvider) {
556
+ pushMessage('system', 'No active session — pick a provider before changing reasoning effort.');
557
+ return;
558
+ }
559
+ if (state !== 'idle' ||
560
+ abortRef.current !== null ||
561
+ hasUnsettledTurn() ||
562
+ queuedRef.current.length > 0 ||
563
+ permissionResolveRef.current !== null ||
564
+ compactingRef.current) {
565
+ pushMessage('system', 'Reasoning effort was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
566
+ return;
567
+ }
568
+ if (effort === undefined) {
569
+ setReasoningEffort(undefined);
570
+ pushMessage('system', 'Reasoning effort reset to the provider default for future main-query turns.');
571
+ return;
572
+ }
573
+ if (!session.reasoningEffortLevels?.includes(effort)) {
574
+ pushMessage('system', `Reasoning effort was not changed: ${effort} is not offered by every usable provider-chain member for this model.`);
575
+ return;
576
+ }
577
+ setReasoningEffort(effort);
578
+ pushMessage('system', `Reasoning effort changed to ${effort} for future main-query turns in this session.`);
579
+ }, [hasUnsettledTurn, pushMessage, session, setReasoningEffort, state]);
580
+ const applyChoiceSelection = useCallback((index) => {
581
+ const picker = choicePickerRef.current;
582
+ if (!picker)
583
+ return;
584
+ const value = picker.values[index];
585
+ if (index < 0 || index >= picker.values.length)
586
+ return;
587
+ setChoicePicker(null);
588
+ if (picker.kind === 'permission-mode') {
589
+ applyPermissionMode(value);
590
+ return;
591
+ }
592
+ applyReasoningEffort(value);
593
+ }, [applyPermissionMode, applyReasoningEffort, setChoicePicker]);
594
+ const sendCopyRequest = useCallback((index) => {
595
+ const picker = copyPickerRef.current;
596
+ const target = picker?.targets[index];
597
+ if (!picker || !target)
598
+ return;
599
+ // Release the overlay synchronously before its transcript notice lands.
600
+ // The queue pump still observes React state and can proceed only after
601
+ // this event has finished publishing the result.
602
+ setCopyPicker(null);
603
+ const result = writeClipboardText(target.text, {
604
+ isTTY: stdout.isTTY,
605
+ write: writeStdout,
606
+ });
607
+ const origin = picker.provenance === 'persisted'
608
+ ? 'the latest persisted assistant output'
609
+ : 'the latest normally completed answer';
610
+ const selection = target.kind === 'whole' ? origin : `${target.label} from ${origin}`;
611
+ switch (result.kind) {
612
+ case 'request-sent':
613
+ pushMessage('system', `Copy request sent for ${selection} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
614
+ break;
615
+ case 'unavailable':
616
+ pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
617
+ break;
618
+ case 'too-large':
619
+ pushMessage('system', `Cannot send this selection to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated.`);
620
+ break;
621
+ case 'write-failed':
622
+ pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
623
+ break;
624
+ default: {
625
+ const exhaustive = result;
626
+ void exhaustive;
627
+ }
628
+ }
629
+ }, [pushMessage, setCopyPicker, stdout.isTTY, writeStdout]);
503
630
  const sendTerminalNotification = useCallback((notification) => {
504
631
  if (!terminalNotificationEnabled(ctx.tui?.notifications, notification))
505
632
  return;
@@ -695,6 +822,8 @@ export function App({ ctx: initialCtx }) {
695
822
  cancelPendingLogin();
696
823
  setPickerNotice(null);
697
824
  setKeyEntryFor(null);
825
+ setPickerDetected(null);
826
+ setPickerSelectionKind('provider-and-model');
698
827
  setPickerInitialView('subscriptions');
699
828
  setPhase('picker');
700
829
  }, [cancelPendingLogin, pushMessage]);
@@ -816,6 +945,9 @@ export function App({ ctx: initialCtx }) {
816
945
  return;
817
946
  setDetected(probe.detected);
818
947
  if (probe.needsRepickReason) {
948
+ setPickerDetected(null);
949
+ setPickerSelectionKind('provider-and-model');
950
+ setPickerInitialView('providers');
819
951
  pushMessage('system', probe.needsRepickReason);
820
952
  setPickerNotice(probe.needsRepickReason);
821
953
  setPhase('picker');
@@ -832,6 +964,9 @@ export function App({ ctx: initialCtx }) {
832
964
  // so the chain it declares — model pins and fallbacks included — is
833
965
  // still the operator's answer once one is supplied.
834
966
  savedPrefsRef.current = probe.preferences;
967
+ setPickerDetected(null);
968
+ setPickerSelectionKind('provider-and-model');
969
+ setPickerInitialView('providers');
835
970
  setKeyEntryFor(probe.credentialGap.providerId);
836
971
  pushMessage('system', probe.credentialGap.reason);
837
972
  setPickerNotice(probe.credentialGap.reason);
@@ -839,9 +974,51 @@ export function App({ ctx: initialCtx }) {
839
974
  return;
840
975
  }
841
976
  if (probe.preferences) {
977
+ setPickerDetected(null);
978
+ setPickerSelectionKind('provider-and-model');
842
979
  await hydrateSession(probe.preferences, probe.detected, signal);
843
980
  return;
844
981
  }
982
+ const signedIn = signedInSubscriptionProviders(probe.detected);
983
+ if (signedIn.length === 1) {
984
+ const chosen = signedIn[0];
985
+ if (!chosen)
986
+ throw new Error('A signed-in provider disappeared during discovery.');
987
+ const automaticPreferences = {
988
+ version: 3,
989
+ providers: [{ id: chosen.entry.id }],
990
+ subagents: { active: [] },
991
+ };
992
+ // Use the picker-grade transactional admission even though no picker had
993
+ // to be drawn. A provider-less candidate must leave an actionable choice,
994
+ // not strand first run on the disabled unhealthy screen.
995
+ const admissionSignal = signal ?? appLifetime.signal;
996
+ try {
997
+ await hydrateSession(automaticPreferences, probe.detected, admissionSignal);
998
+ return;
999
+ }
1000
+ catch (error) {
1001
+ if (admissionSignal.aborted)
1002
+ return;
1003
+ setPickerDetected(null);
1004
+ setPickerSelectionKind('provider-and-model');
1005
+ setPickerInitialView('providers');
1006
+ setPickerNotice(`The signed-in ${chosen.entry.label} session could not start: ${error instanceof Error ? error.message : String(error)}`);
1007
+ setPhase('picker');
1008
+ return;
1009
+ }
1010
+ }
1011
+ if (signedIn.length > 1) {
1012
+ setPickerDetected(signedIn);
1013
+ setPickerSelectionKind('signed-in-subscription');
1014
+ setPickerInitialView('providers');
1015
+ setPickerNotice('Claude and Codex subscriptions are already signed in on this device. Choose which one Namzu should use.');
1016
+ setPhase('picker');
1017
+ return;
1018
+ }
1019
+ setPickerDetected(null);
1020
+ setPickerSelectionKind('provider-and-model');
1021
+ setPickerInitialView('providers');
845
1022
  setPhase('picker');
846
1023
  }
847
1024
  catch (err) {
@@ -850,7 +1027,7 @@ export function App({ ctx: initialCtx }) {
850
1027
  setPhase('unhealthy');
851
1028
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
852
1029
  }
853
- }, [hydrateSession, pushMessage]);
1030
+ }, [appLifetime.signal, hydrateSession, pushMessage]);
854
1031
  // `startOrFinishLogin` is declared above `runProbe` and calls it, so it
855
1032
  // reads the current one through a ref rather than closing over a stale
856
1033
  // binding or forcing the two into a declaration order that reads backwards.
@@ -1959,10 +2136,16 @@ export function App({ ctx: initialCtx }) {
1959
2136
  // been solved — the session behind this picker is running.
1960
2137
  setPickerNotice(null);
1961
2138
  setKeyEntryFor(null);
2139
+ setPickerDetected(null);
2140
+ setPickerSelectionKind('provider-and-model');
1962
2141
  setPickerInitialView('providers');
1963
2142
  setPhase('picker');
1964
2143
  return;
1965
2144
  case 'permission-mode': {
2145
+ applyPermissionMode(slash.mode);
2146
+ return;
2147
+ }
2148
+ case 'permission-mode-picker': {
1966
2149
  if (!session?.hasProvider) {
1967
2150
  pushMessage('system', 'No active session — pick a provider before changing permissions.');
1968
2151
  return;
@@ -1972,24 +2155,34 @@ export function App({ ctx: initialCtx }) {
1972
2155
  hasUnsettledTurn() ||
1973
2156
  queuedRef.current.length > 0 ||
1974
2157
  permissionResolveRef.current !== null ||
1975
- compactingRef.current) {
1976
- pushMessage('system', 'Permission mode was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
1977
- return;
1978
- }
1979
- if (!session.resetApprovalLatch) {
1980
- pushMessage('system', 'Permission mode was not changed: this embedded session cannot revoke an earlier "approve all" choice. Reconnect it before changing modes.');
2158
+ compactingRef.current ||
2159
+ !session.resetApprovalLatch) {
2160
+ pushMessage('system', 'Permission choices are unavailable until the active turn, prompt, compaction, queued work, or embedded-session approval latch can be safely settled.');
1981
2161
  return;
1982
2162
  }
1983
- session.resetApprovalLatch();
1984
- permissionModeRef.current = slash.mode;
1985
- permissionModeSourceRef.current = 'session';
1986
- setPermissionModeState(slash.mode);
1987
- pushMessage('system', `Permission mode changed to ${slash.mode}. Any earlier "approve all" choice was revoked. Declarative deny rules and the built-in safety gate still take precedence.`);
2163
+ const values = ['prompt', 'auto', 'strict'];
2164
+ setSelectedChoice(Math.max(0, values.indexOf(permissionModeRef.current)));
2165
+ setChoicePicker({
2166
+ kind: 'permission-mode',
2167
+ title: 'Select Permission Mode',
2168
+ notice: permissionPickerNotice(session),
2169
+ values,
2170
+ options: values.map((mode) => ({
2171
+ label: mode,
2172
+ description: permissionModeDescription(mode),
2173
+ current: mode === permissionModeRef.current,
2174
+ })),
2175
+ });
1988
2176
  return;
1989
2177
  }
1990
2178
  case 'reasoning-effort': {
1991
- if (!session?.hasProvider) {
1992
- pushMessage('system', 'No active session — pick a provider before changing reasoning effort.');
2179
+ applyReasoningEffort(slash.effort ?? undefined);
2180
+ return;
2181
+ }
2182
+ case 'reasoning-effort-picker': {
2183
+ const levels = session?.reasoningEffortLevels;
2184
+ if (!session?.hasProvider || levels === undefined) {
2185
+ pushMessage('system', 'No exact reasoning-effort menu is available for the current session.');
1993
2186
  return;
1994
2187
  }
1995
2188
  if (state !== 'idle' ||
@@ -1998,20 +2191,22 @@ export function App({ ctx: initialCtx }) {
1998
2191
  queuedRef.current.length > 0 ||
1999
2192
  permissionResolveRef.current !== null ||
2000
2193
  compactingRef.current) {
2001
- pushMessage('system', 'Reasoning effort was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
2002
- return;
2003
- }
2004
- if (slash.effort === null) {
2005
- setReasoningEffort(undefined);
2006
- pushMessage('system', 'Reasoning effort reset to the provider default for future main-query turns.');
2007
- return;
2008
- }
2009
- if (!session.reasoningEffortLevels?.includes(slash.effort)) {
2010
- pushMessage('system', `Reasoning effort was not changed: ${slash.effort} is not offered by every usable provider-chain member for this model.`);
2194
+ pushMessage('system', 'Reasoning-effort choices are unavailable until the active turn, prompt, compaction, and queued work settle.');
2011
2195
  return;
2012
2196
  }
2013
- setReasoningEffort(slash.effort);
2014
- pushMessage('system', `Reasoning effort changed to ${slash.effort} for future main-query turns in this session.`);
2197
+ const values = [undefined, ...levels];
2198
+ const current = reasoningEffortRef.current;
2199
+ setSelectedChoice(Math.max(0, values.findIndex((value) => value === current)));
2200
+ setChoicePicker({
2201
+ kind: 'reasoning-effort',
2202
+ title: `Select Reasoning Level for ${session.modelSummary ?? 'current model'}`,
2203
+ values,
2204
+ options: values.map((effort) => ({
2205
+ label: effort ?? 'default',
2206
+ description: reasoningEffortDescription(effort),
2207
+ current: effort === current,
2208
+ })),
2209
+ });
2015
2210
  return;
2016
2211
  }
2017
2212
  case 'login':
@@ -2392,30 +2587,11 @@ export function App({ ctx: initialCtx }) {
2392
2587
  pushMessage('system', 'Nothing to copy yet — /copy applies to the latest assistant answer that finished normally.');
2393
2588
  return;
2394
2589
  }
2395
- const result = writeClipboardText(target.text, {
2396
- isTTY: stdout.isTTY,
2397
- write: writeStdout,
2590
+ setSelectedCopy(0);
2591
+ setCopyPicker({
2592
+ targets: copyTargetsForResponse(target.text),
2593
+ provenance: target.provenance,
2398
2594
  });
2399
- switch (result.kind) {
2400
- case 'request-sent':
2401
- pushMessage('system', `Copy request sent for the ${target.provenance === 'persisted'
2402
- ? 'latest persisted assistant output'
2403
- : 'latest normally completed answer'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
2404
- break;
2405
- case 'unavailable':
2406
- pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
2407
- break;
2408
- case 'too-large':
2409
- pushMessage('system', `Cannot send this answer to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated.`);
2410
- break;
2411
- case 'write-failed':
2412
- pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
2413
- break;
2414
- default: {
2415
- const exhaustive = result;
2416
- void exhaustive;
2417
- }
2418
- }
2419
2595
  return;
2420
2596
  }
2421
2597
  case 'raw': {
@@ -2501,6 +2677,8 @@ export function App({ ctx: initialCtx }) {
2501
2677
  }, [
2502
2678
  activeSkills,
2503
2679
  advanceQueueContinuation,
2680
+ applyPermissionMode,
2681
+ applyReasoningEffort,
2504
2682
  ctx.cwd,
2505
2683
  doResume,
2506
2684
  enqueueQueued,
@@ -2510,11 +2688,45 @@ export function App({ ctx: initialCtx }) {
2510
2688
  pushMessage,
2511
2689
  rawOutput,
2512
2690
  resetTranscript,
2691
+ setChoicePicker,
2692
+ setCopyPicker,
2513
2693
  setReasoningEffort,
2514
2694
  slashCtx,
2515
2695
  startFreshConversation,
2516
2696
  state,
2517
2697
  ]);
2698
+ // Keep the footer on the same durable goal record the driver mutates. A ref
2699
+ // alone cannot repaint React, so every goal mutation bumps goalDriveVersion;
2700
+ // generation/session checks stop a late disk read labelling a resumed chat.
2701
+ useEffect(() => {
2702
+ void goalDriveVersion;
2703
+ const sessions = sessionsRef.current;
2704
+ const scope = scopeRef.current;
2705
+ if (!sessions?.goals || !scope) {
2706
+ setGoalStatus(null);
2707
+ return;
2708
+ }
2709
+ const generation = conversationGenRef.current;
2710
+ const sessionId = scope.sessionId;
2711
+ let disposed = false;
2712
+ void sessions.goals
2713
+ .getGoal(sessionId, sessions.tenantId)
2714
+ .then((goal) => {
2715
+ if (disposed ||
2716
+ conversationGenRef.current !== generation ||
2717
+ scopeRef.current?.sessionId !== sessionId) {
2718
+ return;
2719
+ }
2720
+ setGoalStatus(goal);
2721
+ })
2722
+ .catch(() => {
2723
+ if (!disposed && conversationGenRef.current === generation)
2724
+ setGoalStatus(null);
2725
+ });
2726
+ return () => {
2727
+ disposed = true;
2728
+ };
2729
+ }, [goalDriveVersion, phase, session]);
2518
2730
  // Admit automatic work only at a whole-App durable boundary. This effect
2519
2731
  // reserves; it never starts a turn itself. The one queue pump below remains
2520
2732
  // the sole starter, which is what keeps a human prompt that arrives during
@@ -2535,6 +2747,8 @@ export function App({ ctx: initialCtx }) {
2535
2747
  conversationMutationRef.current ||
2536
2748
  exportingRef.current ||
2537
2749
  compactingRef.current ||
2750
+ choicePickerRef.current ||
2751
+ copyPickerRef.current ||
2538
2752
  goalCommandInFlightRef.current) {
2539
2753
  return;
2540
2754
  }
@@ -2559,6 +2773,8 @@ export function App({ ctx: initialCtx }) {
2559
2773
  conversationMutationRef.current ||
2560
2774
  exportingRef.current ||
2561
2775
  compactingRef.current ||
2776
+ choicePickerRef.current ||
2777
+ copyPickerRef.current ||
2562
2778
  goalCommandInFlightRef.current ||
2563
2779
  queuedRef.current.length > 0 ||
2564
2780
  !goalActivation.isArmed(sessionId, armed)) {
@@ -2616,6 +2832,8 @@ export function App({ ctx: initialCtx }) {
2616
2832
  conversationMutation,
2617
2833
  session,
2618
2834
  state,
2835
+ choicePicker,
2836
+ copyPicker,
2619
2837
  wakeGoalDriver,
2620
2838
  ]);
2621
2839
  // Drain the queue: when a turn settles (idle) and nothing is running,
@@ -2630,6 +2848,8 @@ export function App({ ctx: initialCtx }) {
2630
2848
  conversationMutationRef.current ||
2631
2849
  exportingRef.current ||
2632
2850
  compactingRef.current ||
2851
+ choicePickerRef.current ||
2852
+ copyPickerRef.current ||
2633
2853
  goalCommandInFlightRef.current)
2634
2854
  return;
2635
2855
  // Read and remove from the synchronous source of truth. `queued` is the
@@ -2639,7 +2859,17 @@ export function App({ ctx: initialCtx }) {
2639
2859
  const next = dequeueQueued();
2640
2860
  if (next !== undefined)
2641
2861
  void runTurn(next);
2642
- }, [state, phase, queued, queuePause, dequeueQueued, hasUnsettledTurn, runTurn]);
2862
+ }, [
2863
+ state,
2864
+ phase,
2865
+ queued,
2866
+ queuePause,
2867
+ choicePicker,
2868
+ copyPicker,
2869
+ dequeueQueued,
2870
+ hasUnsettledTurn,
2871
+ runTurn,
2872
+ ]);
2643
2873
  // One-shot update check on launch.
2644
2874
  // Best-effort; surfaces a single notice when something newer is out.
2645
2875
  const updateCheckedRef = useRef(false);
@@ -2654,7 +2884,7 @@ export function App({ ctx: initialCtx }) {
2654
2884
  pushMessage('system', `⬆ Update available:\n${lines.join('\n')}`);
2655
2885
  });
2656
2886
  }, [phase, ctx.version, pushMessage]);
2657
- const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal) => {
2887
+ const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal, revealAllOnFailure = false) => {
2658
2888
  try {
2659
2889
  await hydrateSession(prefs, detectedNow, signal);
2660
2890
  }
@@ -2664,6 +2894,11 @@ export function App({ ctx: initialCtx }) {
2664
2894
  // choice stays on the picker with the actionable construction error.
2665
2895
  if (signal.aborted)
2666
2896
  return;
2897
+ if (revealAllOnFailure) {
2898
+ setPickerDetected(null);
2899
+ setPickerSelectionKind('provider-and-model');
2900
+ setPickerInitialView('providers');
2901
+ }
2667
2902
  setPickerNotice(`Could not start the selected provider: ${err instanceof Error ? err.message : String(err)}`);
2668
2903
  }
2669
2904
  }, [hydrateSession]);
@@ -2679,6 +2914,8 @@ export function App({ ctx: initialCtx }) {
2679
2914
  const next = [credential, ...detected.filter((d) => d.entry.id !== credential.entry.id)];
2680
2915
  setDetected(next);
2681
2916
  setKeyEntryFor(null);
2917
+ setPickerDetected(null);
2918
+ setPickerSelectionKind('provider-and-model');
2682
2919
  setPickerNotice(null);
2683
2920
  // The disposition, not the key. `credential` never reaches a message.
2684
2921
  pushMessage('system', disposition);
@@ -2720,7 +2957,9 @@ export function App({ ctx: initialCtx }) {
2720
2957
  pushMessage('system', `Could not save preferences: ${err instanceof Error ? err.message : String(err)}`);
2721
2958
  return;
2722
2959
  }
2723
- void hydrateFromPicker(prefs, detected, signal);
2960
+ // Keep a narrowed signed-in roster stable while construction is pending.
2961
+ // Only a real refusal broadens back to the general, sign-in-capable picker.
2962
+ void hydrateFromPicker(prefs, detected, signal, true);
2724
2963
  }, [detected, hydrateFromPicker, pushMessage]);
2725
2964
  /**
2726
2965
  * Leaving the picker returns to whatever was on screen before it.
@@ -2742,6 +2981,8 @@ export function App({ ctx: initialCtx }) {
2742
2981
  */
2743
2982
  const handlePickerCancel = useCallback(() => {
2744
2983
  setPickerInitialView('providers');
2984
+ setPickerDetected(null);
2985
+ setPickerSelectionKind('provider-and-model');
2745
2986
  if (session?.hasProvider) {
2746
2987
  setPhase('ready');
2747
2988
  return;
@@ -2867,6 +3108,62 @@ export function App({ ctx: initialCtx }) {
2867
3108
  resolvePermission({ kind: 'approve-all' });
2868
3109
  return;
2869
3110
  }
3111
+ // Finite slash-command choices own the keyboard below a live permission
3112
+ // request and above the ordinary composer. Their values are a captured
3113
+ // menu, so a provider/session change cannot silently reinterpret Enter.
3114
+ if (choicePickerRef.current) {
3115
+ const options = choicePickerRef.current.options;
3116
+ if (key.escape || (key.ctrl && input === 'c')) {
3117
+ setChoicePicker(null);
3118
+ return;
3119
+ }
3120
+ if (key.upArrow) {
3121
+ setSelectedChoice((index) => Math.max(0, index - 1));
3122
+ return;
3123
+ }
3124
+ if (key.downArrow) {
3125
+ setSelectedChoice((index) => Math.min(options.length - 1, index + 1));
3126
+ return;
3127
+ }
3128
+ if (key.return) {
3129
+ applyChoiceSelection(selectedChoice);
3130
+ return;
3131
+ }
3132
+ if (/^[1-9]$/.test(input)) {
3133
+ const index = Number(input) - 1;
3134
+ if (options[index])
3135
+ applyChoiceSelection(index);
3136
+ }
3137
+ return;
3138
+ }
3139
+ // `/copy` holds a source snapshot, not a lifecycle phase. Permission
3140
+ // stays above it in this handler so an approval request that arrives
3141
+ // while the picker is open cannot have its keys stolen by the chooser.
3142
+ if (copyPickerRef.current) {
3143
+ const targets = copyPickerRef.current.targets;
3144
+ if (key.escape || (key.ctrl && input === 'c')) {
3145
+ setCopyPicker(null);
3146
+ return;
3147
+ }
3148
+ if (key.upArrow) {
3149
+ setSelectedCopy((index) => Math.max(0, index - 1));
3150
+ return;
3151
+ }
3152
+ if (key.downArrow) {
3153
+ setSelectedCopy((index) => Math.min(targets.length - 1, index + 1));
3154
+ return;
3155
+ }
3156
+ if (key.return) {
3157
+ sendCopyRequest(selectedCopy);
3158
+ return;
3159
+ }
3160
+ if (/^[1-9]$/.test(input)) {
3161
+ const index = Number(input) - 1;
3162
+ if (targets[index])
3163
+ sendCopyRequest(index);
3164
+ }
3165
+ return;
3166
+ }
2870
3167
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
2871
3168
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
2872
3169
  if (key.escape && abortRef.current) {
@@ -2943,16 +3240,29 @@ export function App({ ctx: initialCtx }) {
2943
3240
  // Background is left natural — we inherit the terminal's own background
2944
3241
  // and only theme the foreground. Forcing
2945
3242
  // a filled bg left mismatched patches around bordered areas, so we don't.
2946
- 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: detected, currentProvider: currentProvider, 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 }) : null, _jsxs(ComposerFrame, { focus: state === 'idle' && phase === 'ready' && conversationMutation === null, hidden: permission !== null, children: [queued.length > 0 && permission === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
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
2947
3251
  ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
2948
3252
  : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
2949
3253
  state === 'awaiting-permission' ||
2950
3254
  compacting ||
2951
- conversationMutation !== null, hidden: permission !== null,
3255
+ conversationMutation !== null, hidden: permission !== null || choicePicker !== null || copyPicker !== null,
2952
3256
  // A turn is running, so Esc is the interrupt and not
2953
3257
  // the composer's clear.
2954
- 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
2955
- ? `${session.modelSummary}${reasoningEffort ? ` · effort ${reasoningEffort}` : ''}`
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' &&
3260
+ conversationMutation === null &&
3261
+ !compacting &&
3262
+ permission === null &&
3263
+ choicePicker === null &&
3264
+ copyPicker === null
3265
+ ? goalStatusLabel(goalStatus)
2956
3266
  : null, state: state, hint: conversationMutation === 'fork'
2957
3267
  ? 'forking conversation — input is paused'
2958
3268
  : conversationMutation === 'edit'
@@ -2961,7 +3271,15 @@ export function App({ ctx: initialCtx }) {
2961
3271
  ? 'starting a fresh conversation — input is paused'
2962
3272
  : compacting
2963
3273
  ? 'compacting conversation — input is paused'
2964
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
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 }) })] })] }));
2965
3283
  }
2966
3284
  function Banner({ version, session, permissionMode, cwd, }) {
2967
3285
  const cols = process.stdout.columns ?? 80;
@@ -3019,6 +3337,62 @@ canReturn = false) {
3019
3337
  return 'agent is working — esc to interrupt';
3020
3338
  return '/help · Esc×2 edit previous · @file / Ctrl+V attach · Ctrl+C ×2 to exit';
3021
3339
  }
3340
+ function goalStatusLabel(goal) {
3341
+ if (!goal)
3342
+ return null;
3343
+ switch (goal.phase) {
3344
+ case 'active':
3345
+ return 'Pursuing goal';
3346
+ case 'paused':
3347
+ return 'Goal paused (/goal resume)';
3348
+ case 'blocked':
3349
+ return 'Goal stalled (/goal resume)';
3350
+ case 'complete':
3351
+ return 'Goal achieved';
3352
+ }
3353
+ }
3354
+ function permissionModeDescription(mode) {
3355
+ switch (mode) {
3356
+ case 'prompt':
3357
+ return 'Ask before an undecided tool call runs';
3358
+ case 'auto':
3359
+ return 'Approve undecided calls unless a rule or safety gate refuses';
3360
+ case 'strict':
3361
+ return 'Refuse undecided calls; explicit allow rules still work';
3362
+ }
3363
+ }
3364
+ function permissionPickerNotice(session) {
3365
+ const notices = [];
3366
+ if (session.approvalLatched()) {
3367
+ notices.push('Approve all is active; applying any mode revokes it.');
3368
+ }
3369
+ const exempt = session.promptExemptTools();
3370
+ if (exempt.length > 0)
3371
+ notices.push(`Never prompted: ${exempt.join(', ')}.`);
3372
+ return notices.length > 0 ? notices.join(' ') : undefined;
3373
+ }
3374
+ function reasoningEffortDescription(effort) {
3375
+ switch (effort) {
3376
+ case undefined:
3377
+ return 'Use the provider and model default';
3378
+ case 'none':
3379
+ return 'No deliberate reasoning where the model supports it';
3380
+ case 'minimal':
3381
+ return 'Fastest responses with minimal reasoning';
3382
+ case 'low':
3383
+ return 'Fast responses with light reasoning';
3384
+ case 'medium':
3385
+ return 'Balance speed and reasoning depth';
3386
+ case 'high':
3387
+ return 'Deeper reasoning for complex problems';
3388
+ case 'xhigh':
3389
+ return 'Very deep reasoning for hard problems';
3390
+ case 'max':
3391
+ return 'Maximum reasoning depth offered by this model';
3392
+ case 'ultra':
3393
+ return 'Highest host-level reasoning setting when supported';
3394
+ }
3395
+ }
3022
3396
  /**
3023
3397
  * The summary text, as collapsible detail under the compaction row.
3024
3398
  *