@namzu/cli 13.0.0 → 14.1.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 (79) hide show
  1. package/README.md +13 -2
  2. package/dist/commands/login.d.ts +2 -0
  3. package/dist/commands/login.d.ts.map +1 -1
  4. package/dist/commands/login.js +79 -21
  5. package/dist/commands/login.js.map +1 -1
  6. package/dist/doctor/checks/credentials.d.ts.map +1 -1
  7. package/dist/doctor/checks/credentials.js +7 -1
  8. package/dist/doctor/checks/credentials.js.map +1 -1
  9. package/dist/integrations/providers/codex-device-login.d.ts +27 -0
  10. package/dist/integrations/providers/codex-device-login.d.ts.map +1 -0
  11. package/dist/integrations/providers/codex-device-login.js +195 -0
  12. package/dist/integrations/providers/codex-device-login.js.map +1 -0
  13. package/dist/integrations/providers/codex-oauth.d.ts +10 -0
  14. package/dist/integrations/providers/codex-oauth.d.ts.map +1 -0
  15. package/dist/integrations/providers/codex-oauth.js +95 -0
  16. package/dist/integrations/providers/codex-oauth.js.map +1 -0
  17. package/dist/integrations/providers/credential-store.d.ts +23 -4
  18. package/dist/integrations/providers/credential-store.d.ts.map +1 -1
  19. package/dist/integrations/providers/credential-store.js +125 -19
  20. package/dist/integrations/providers/credential-store.js.map +1 -1
  21. package/dist/integrations/providers/discover.d.ts +39 -13
  22. package/dist/integrations/providers/discover.d.ts.map +1 -1
  23. package/dist/integrations/providers/discover.js +122 -44
  24. package/dist/integrations/providers/discover.js.map +1 -1
  25. package/dist/integrations/providers/harness-credentials.d.ts +36 -0
  26. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -0
  27. package/dist/integrations/providers/harness-credentials.js +146 -0
  28. package/dist/integrations/providers/harness-credentials.js.map +1 -0
  29. package/dist/integrations/providers/identity.d.ts +8 -15
  30. package/dist/integrations/providers/identity.d.ts.map +1 -1
  31. package/dist/integrations/providers/identity.js +8 -15
  32. package/dist/integrations/providers/identity.js.map +1 -1
  33. package/dist/integrations/providers/index.d.ts +6 -3
  34. package/dist/integrations/providers/index.d.ts.map +1 -1
  35. package/dist/integrations/providers/index.js +5 -2
  36. package/dist/integrations/providers/index.js.map +1 -1
  37. package/dist/integrations/providers/oauth.d.ts +2 -2
  38. package/dist/integrations/providers/oauth.d.ts.map +1 -1
  39. package/dist/integrations/providers/oauth.js +8 -5
  40. package/dist/integrations/providers/oauth.js.map +1 -1
  41. package/dist/integrations/providers/register.d.ts.map +1 -1
  42. package/dist/integrations/providers/register.js +9 -1
  43. package/dist/integrations/providers/register.js.map +1 -1
  44. package/dist/integrations/providers/registry.d.ts +8 -1
  45. package/dist/integrations/providers/registry.d.ts.map +1 -1
  46. package/dist/integrations/providers/registry.js +29 -0
  47. package/dist/integrations/providers/registry.js.map +1 -1
  48. package/dist/tui/App.d.ts +5 -5
  49. package/dist/tui/App.d.ts.map +1 -1
  50. package/dist/tui/App.js +257 -98
  51. package/dist/tui/App.js.map +1 -1
  52. package/dist/tui/CopyPicker.d.ts +8 -0
  53. package/dist/tui/CopyPicker.d.ts.map +1 -0
  54. package/dist/tui/CopyPicker.js +20 -0
  55. package/dist/tui/CopyPicker.js.map +1 -0
  56. package/dist/tui/Picker.d.ts +12 -3
  57. package/dist/tui/Picker.d.ts.map +1 -1
  58. package/dist/tui/Picker.js +88 -27
  59. package/dist/tui/Picker.js.map +1 -1
  60. package/dist/tui/agent.d.ts +2 -5
  61. package/dist/tui/agent.d.ts.map +1 -1
  62. package/dist/tui/agent.js +112 -17
  63. package/dist/tui/agent.js.map +1 -1
  64. package/dist/tui/copy-targets.d.ts +23 -0
  65. package/dist/tui/copy-targets.d.ts.map +1 -0
  66. package/dist/tui/copy-targets.js +189 -0
  67. package/dist/tui/copy-targets.js.map +1 -0
  68. package/dist/tui/login-prompt.d.ts +5 -0
  69. package/dist/tui/login-prompt.d.ts.map +1 -1
  70. package/dist/tui/login-prompt.js +14 -0
  71. package/dist/tui/login-prompt.js.map +1 -1
  72. package/dist/tui/model-choices.d.ts.map +1 -1
  73. package/dist/tui/model-choices.js +6 -1
  74. package/dist/tui/model-choices.js.map +1 -1
  75. package/dist/tui/slashCommands.d.ts +1 -1
  76. package/dist/tui/slashCommands.d.ts.map +1 -1
  77. package/dist/tui/slashCommands.js +2 -2
  78. package/dist/tui/slashCommands.js.map +1 -1
  79. package/package.json +6 -6
package/dist/tui/App.js CHANGED
@@ -5,51 +5,53 @@ 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
- import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, kernelHostCommands, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, } from '@namzu/sdk';
15
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
16
14
  import { join, relative } from 'node:path';
15
+ import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
17
17
  import { useCallback, useEffect, useRef, useState } from 'react';
18
- import { visibleProjectInstructionPath } from '../context/project-path.js';
19
18
  import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
20
- import { beginSubscriptionLogin, clearStoredSubscriptionCredential, credentialsPath, primaryProvider, readStoredSubscriptionCredential, writePreferences, } from '../integrations/providers/index.js';
19
+ import { visibleProjectInstructionPath } from '../context/project-path.js';
21
20
  import { writeClipboardText } from '../integrations/clipboard/text.js';
22
21
  import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
23
- import { describeLoginOutcome, describeLoginStart, describeLogout } from './login-prompt.js';
24
- import { openInBrowser } from './open-browser.js';
22
+ import { beginCodexDeviceLogin, beginSubscriptionLogin, clearAllStoredCredentials, credentialsPath, primaryProvider, readStoredCodexCredential, readStoredSubscriptionCredential, signedInSubscriptionProviders, writePreferences, } from '../integrations/providers/index.js';
23
+ import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
24
+ import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
25
25
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
26
+ import { checkUpdates } from '../integrations/updates.js';
26
27
  import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.js';
27
28
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
28
- import { checkUpdates } from '../integrations/updates.js';
29
- import { LiveActivity, formatElapsed } from './LiveActivity.js';
30
- import { bottomSpacerRows } from './bottom-spacer.js';
31
- import { expandFileMentions } from './mentions.js';
29
+ import { discoverUserCommands } from '../user-commands/store.js';
32
30
  import { Composer } from './Composer.js';
31
+ import { CopyPicker } from './CopyPicker.js';
33
32
  import { EditPromptPicker } from './EditPromptPicker.js';
34
- import { TrustPrompt } from './TrustPrompt.js';
35
- import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
33
+ import { LiveActivity, formatElapsed } from './LiveActivity.js';
36
34
  import { PermissionOverlay } from './PermissionOverlay.js';
37
- import { approvalIsDeliberate } from './consent-timing.js';
38
35
  import { Picker } from './Picker.js';
36
+ import { ResumePicker } from './ResumePicker.js';
39
37
  import { StatusBar } from './StatusBar.js';
40
38
  import { Transcript, willCollapse } from './Transcript.js';
41
- import { liveWindow, transcriptLines } from './live-window.js';
42
- import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
43
- import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
39
+ import { TrustPrompt } from './TrustPrompt.js';
44
40
  import { createAgentSession, probeAgentSession, } from './agent.js';
45
- import { ResumePicker } from './ResumePicker.js';
46
- import { editablePrompts } from './edit-prompts.js';
41
+ import { bottomSpacerRows } from './bottom-spacer.js';
42
+ import { keepRecentRows } from './compact-transcript.js';
43
+ import { approvalIsDeliberate } from './consent-timing.js';
47
44
  import { planTurnPublication } from './conversation-history.js';
48
- import { discoverUserCommands } from '../user-commands/store.js';
45
+ import { copyTargetsForResponse, } from './copy-targets.js';
46
+ import { editablePrompts } from './edit-prompts.js';
47
+ import { liveWindow, transcriptLines } from './live-window.js';
48
+ import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, } from './login-prompt.js';
49
+ import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
50
+ import { expandFileMentions } from './mentions.js';
51
+ import { openInBrowser } from './open-browser.js';
49
52
  import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
50
53
  import { splitCompleteBlocks } from './stream-blocks.js';
51
54
  import { theme } from './theme.js';
52
- import { keepRecentRows } from './compact-transcript.js';
53
55
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
54
56
  /** Last non-empty assistant text in a durable conversation, newest first. */
55
57
  function latestAssistantOutput(messages) {
@@ -213,6 +215,12 @@ export function App({ ctx: initialCtx }) {
213
215
  * chosen yet would be answering a question that was not asked.
214
216
  */
215
217
  const [keyEntryFor, setKeyEntryFor] = useState(null);
218
+ /** Which decision owns the next picker mount. */
219
+ const [pickerInitialView, setPickerInitialView] = useState('providers');
220
+ /** A narrowed first-run roster; null keeps the complete discovered list. */
221
+ const [pickerDetected, setPickerDetected] = useState(null);
222
+ /** Whether Enter accepts the provider directly or continues to model choice. */
223
+ const [pickerSelectionKind, setPickerSelectionKind] = useState('provider-and-model');
216
224
  /** The chain read from disk, held while the picker repairs its credential. */
217
225
  const savedPrefsRef = useRef(null);
218
226
  /**
@@ -312,6 +320,14 @@ export function App({ ctx: initialCtx }) {
312
320
  const [editList, setEditList] = useState([]);
313
321
  const [selectedEdit, setSelectedEdit] = useState(0);
314
322
  const editCommittedRef = useRef(false);
323
+ /** `/copy` owns this exact response snapshot until selection or cancellation. */
324
+ const [copyPicker, setCopyPickerState] = useState(null);
325
+ const copyPickerRef = useRef(null);
326
+ const [selectedCopy, setSelectedCopy] = useState(0);
327
+ const setCopyPicker = useCallback((next) => {
328
+ copyPickerRef.current = next;
329
+ setCopyPickerState(next);
330
+ }, []);
315
331
  const [composerDraft, setComposerDraft] = useState(null);
316
332
  const composerDraftTokenRef = useRef(0);
317
333
  const exitArmedRef = useRef(false);
@@ -328,12 +344,18 @@ export function App({ ctx: initialCtx }) {
328
344
  * which the code that comes back cannot be exchanged for anything.
329
345
  */
330
346
  const loginRef = useRef(null);
347
+ const codexLoginRef = useRef(null);
331
348
  const loginAbortCleanupRef = useRef(null);
349
+ /** Revokes automatic first-run construction when this App no longer exists. */
350
+ const [appLifetime] = useState(() => new AbortController());
351
+ useEffect(() => () => appLifetime.abort(new Error('The Namzu TUI was closed.')), [appLifetime]);
332
352
  const cancelPendingLogin = useCallback(() => {
333
353
  loginAbortCleanupRef.current?.();
334
354
  loginAbortCleanupRef.current = null;
335
355
  loginRef.current?.cancel();
336
356
  loginRef.current = null;
357
+ codexLoginRef.current?.cancel();
358
+ codexLoginRef.current = null;
337
359
  }, []);
338
360
  const runProbeRef = useRef(null);
339
361
  useEffect(() => cancelPendingLogin, [cancelPendingLogin]);
@@ -495,6 +517,42 @@ export function App({ ctx: initialCtx }) {
495
517
  ]);
496
518
  return id;
497
519
  }, [nextId]);
520
+ const sendCopyRequest = useCallback((index) => {
521
+ const picker = copyPickerRef.current;
522
+ const target = picker?.targets[index];
523
+ if (!picker || !target)
524
+ return;
525
+ // Release the overlay synchronously before its transcript notice lands.
526
+ // The queue pump still observes React state and can proceed only after
527
+ // this event has finished publishing the result.
528
+ setCopyPicker(null);
529
+ const result = writeClipboardText(target.text, {
530
+ isTTY: stdout.isTTY,
531
+ write: writeStdout,
532
+ });
533
+ const origin = picker.provenance === 'persisted'
534
+ ? 'the latest persisted assistant output'
535
+ : 'the latest normally completed answer';
536
+ const selection = target.kind === 'whole' ? origin : `${target.label} from ${origin}`;
537
+ switch (result.kind) {
538
+ case 'request-sent':
539
+ 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.`);
540
+ break;
541
+ case 'unavailable':
542
+ pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
543
+ break;
544
+ case 'too-large':
545
+ 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.`);
546
+ break;
547
+ case 'write-failed':
548
+ pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
549
+ break;
550
+ default: {
551
+ const exhaustive = result;
552
+ void exhaustive;
553
+ }
554
+ }
555
+ }, [pushMessage, setCopyPicker, stdout.isTTY, writeStdout]);
498
556
  const sendTerminalNotification = useCallback((notification) => {
499
557
  if (!terminalNotificationEnabled(ctx.tui?.notifications, notification))
500
558
  return;
@@ -651,9 +709,9 @@ export function App({ ctx: initialCtx }) {
651
709
  /**
652
710
  * Sign in to a subscription without leaving namzu.
653
711
  *
654
- * Two calls, one command. Bare `/login` starts an attempt and parks it in
655
- * `loginRef`; `/login <address>` finishes that one. Nothing here parses the
656
- * argument — `slashCommands` already decided which of the two this is.
712
+ * A bare `/login` no longer guesses a provider: it mounts the subscription
713
+ * picker first. The argument form remains the paste-completion half of the
714
+ * browser-callback attempt already in flight.
657
715
  *
658
716
  * ## Why it re-probes instead of installing the credential directly
659
717
  *
@@ -681,46 +739,19 @@ export function App({ ctx: initialCtx }) {
681
739
  loginAbortCleanupRef.current?.();
682
740
  loginAbortCleanupRef.current = null;
683
741
  pushMessage('system', describeLoginOutcome(outcome));
684
- if (outcome.ok)
742
+ if (outcome.ok) {
743
+ setPickerInitialView('providers');
685
744
  await runProbeRef.current?.();
745
+ }
686
746
  return;
687
747
  }
688
748
  cancelPendingLogin();
689
- let start;
690
- try {
691
- start = await beginSubscriptionLogin();
692
- }
693
- catch (err) {
694
- pushMessage('system', `Could not start a sign-in: ${err instanceof Error ? err.message : String(err)}`);
695
- return;
696
- }
697
- loginRef.current = start;
698
- pushMessage('system', describeLoginStart({
699
- url: start.url,
700
- loopback: start.loopback,
701
- browserOpened: openInBrowser(start.url),
702
- }));
703
- // The automatic half, when there is one. Not awaited by the caller:
704
- // the composer stays live throughout, so the operator can paste
705
- // instead — or type anything else — while this waits.
706
- const waiting = start.waitForCallback();
707
- if (!waiting)
708
- return;
709
- const outcome = await waiting;
710
- if (loginRef.current !== start)
711
- return; // superseded, or finished by paste
712
- loginRef.current = null;
713
- loginAbortCleanupRef.current?.();
714
- loginAbortCleanupRef.current = null;
715
- start.cancel();
716
- // A cancelled listener resolves to a refusal, and saying "sign-in
717
- // failed" when the operator finished it another way would be a lie
718
- // about their own successful login.
719
- if (!outcome.ok && outcome.reason.includes('cancelled'))
720
- return;
721
- pushMessage('system', describeLoginOutcome(outcome));
722
- if (outcome.ok)
723
- await runProbeRef.current?.();
749
+ setPickerNotice(null);
750
+ setKeyEntryFor(null);
751
+ setPickerDetected(null);
752
+ setPickerSelectionKind('provider-and-model');
753
+ setPickerInitialView('subscriptions');
754
+ setPhase('picker');
724
755
  }, [cancelPendingLogin, pushMessage]);
725
756
  /**
726
757
  * Sign in from the PICKER, where the transcript does not exist.
@@ -737,10 +768,43 @@ export function App({ ctx: initialCtx }) {
737
768
  * no-browser case is handed to `namzu login`, which reads the pasted
738
769
  * address from standard input and exists for exactly that machine.
739
770
  */
740
- const startLoginFromPicker = useCallback(async (signal) => {
771
+ const startLoginFromPicker = useCallback(async (provider, signal) => {
741
772
  if (signal.aborted)
742
773
  return;
743
774
  cancelPendingLogin();
775
+ if (provider === 'codex') {
776
+ let start;
777
+ try {
778
+ start = await beginCodexDeviceLogin({ signal });
779
+ }
780
+ catch (error) {
781
+ if (!signal.aborted) {
782
+ setPickerNotice(`Could not start Codex sign-in: ${error instanceof Error ? error.message : String(error)}`);
783
+ }
784
+ return;
785
+ }
786
+ if (signal.aborted) {
787
+ start.cancel();
788
+ return;
789
+ }
790
+ codexLoginRef.current = start;
791
+ setPickerNotice(describeCodexDeviceLoginStart({
792
+ url: start.url,
793
+ userCode: start.userCode,
794
+ browserOpened: openInBrowser(start.url),
795
+ }));
796
+ const outcome = await start.waitForCompletion();
797
+ if (codexLoginRef.current !== start || signal.aborted)
798
+ return;
799
+ codexLoginRef.current = null;
800
+ start.cancel();
801
+ setPickerNotice(describeLoginOutcome(outcome));
802
+ if (outcome.ok) {
803
+ setPickerInitialView('providers');
804
+ await runProbeRef.current?.(signal);
805
+ }
806
+ return;
807
+ }
744
808
  let start;
745
809
  try {
746
810
  start = await beginSubscriptionLogin({ signal });
@@ -793,8 +857,10 @@ export function App({ ctx: initialCtx }) {
793
857
  if (!outcome.ok && outcome.reason.includes('cancelled'))
794
858
  return;
795
859
  setPickerNotice(describeLoginOutcome(outcome));
796
- if (outcome.ok)
860
+ if (outcome.ok) {
861
+ setPickerInitialView('providers');
797
862
  await runProbeRef.current?.(signal);
863
+ }
798
864
  }, [cancelPendingLogin]);
799
865
  const runProbe = useCallback(async (signal) => {
800
866
  try {
@@ -805,6 +871,9 @@ export function App({ ctx: initialCtx }) {
805
871
  return;
806
872
  setDetected(probe.detected);
807
873
  if (probe.needsRepickReason) {
874
+ setPickerDetected(null);
875
+ setPickerSelectionKind('provider-and-model');
876
+ setPickerInitialView('providers');
808
877
  pushMessage('system', probe.needsRepickReason);
809
878
  setPickerNotice(probe.needsRepickReason);
810
879
  setPhase('picker');
@@ -821,6 +890,9 @@ export function App({ ctx: initialCtx }) {
821
890
  // so the chain it declares — model pins and fallbacks included — is
822
891
  // still the operator's answer once one is supplied.
823
892
  savedPrefsRef.current = probe.preferences;
893
+ setPickerDetected(null);
894
+ setPickerSelectionKind('provider-and-model');
895
+ setPickerInitialView('providers');
824
896
  setKeyEntryFor(probe.credentialGap.providerId);
825
897
  pushMessage('system', probe.credentialGap.reason);
826
898
  setPickerNotice(probe.credentialGap.reason);
@@ -828,9 +900,51 @@ export function App({ ctx: initialCtx }) {
828
900
  return;
829
901
  }
830
902
  if (probe.preferences) {
903
+ setPickerDetected(null);
904
+ setPickerSelectionKind('provider-and-model');
831
905
  await hydrateSession(probe.preferences, probe.detected, signal);
832
906
  return;
833
907
  }
908
+ const signedIn = signedInSubscriptionProviders(probe.detected);
909
+ if (signedIn.length === 1) {
910
+ const chosen = signedIn[0];
911
+ if (!chosen)
912
+ throw new Error('A signed-in provider disappeared during discovery.');
913
+ const automaticPreferences = {
914
+ version: 3,
915
+ providers: [{ id: chosen.entry.id }],
916
+ subagents: { active: [] },
917
+ };
918
+ // Use the picker-grade transactional admission even though no picker had
919
+ // to be drawn. A provider-less candidate must leave an actionable choice,
920
+ // not strand first run on the disabled unhealthy screen.
921
+ const admissionSignal = signal ?? appLifetime.signal;
922
+ try {
923
+ await hydrateSession(automaticPreferences, probe.detected, admissionSignal);
924
+ return;
925
+ }
926
+ catch (error) {
927
+ if (admissionSignal.aborted)
928
+ return;
929
+ setPickerDetected(null);
930
+ setPickerSelectionKind('provider-and-model');
931
+ setPickerInitialView('providers');
932
+ setPickerNotice(`The signed-in ${chosen.entry.label} session could not start: ${error instanceof Error ? error.message : String(error)}`);
933
+ setPhase('picker');
934
+ return;
935
+ }
936
+ }
937
+ if (signedIn.length > 1) {
938
+ setPickerDetected(signedIn);
939
+ setPickerSelectionKind('signed-in-subscription');
940
+ setPickerInitialView('providers');
941
+ setPickerNotice('Claude and Codex subscriptions are already signed in on this device. Choose which one Namzu should use.');
942
+ setPhase('picker');
943
+ return;
944
+ }
945
+ setPickerDetected(null);
946
+ setPickerSelectionKind('provider-and-model');
947
+ setPickerInitialView('providers');
834
948
  setPhase('picker');
835
949
  }
836
950
  catch (err) {
@@ -839,7 +953,7 @@ export function App({ ctx: initialCtx }) {
839
953
  setPhase('unhealthy');
840
954
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
841
955
  }
842
- }, [hydrateSession, pushMessage]);
956
+ }, [appLifetime.signal, hydrateSession, pushMessage]);
843
957
  // `startOrFinishLogin` is declared above `runProbe` and calls it, so it
844
958
  // reads the current one through a ref rather than closing over a stale
845
959
  // binding or forcing the two into a declaration order that reads backwards.
@@ -1948,6 +2062,9 @@ export function App({ ctx: initialCtx }) {
1948
2062
  // been solved — the session behind this picker is running.
1949
2063
  setPickerNotice(null);
1950
2064
  setKeyEntryFor(null);
2065
+ setPickerDetected(null);
2066
+ setPickerSelectionKind('provider-and-model');
2067
+ setPickerInitialView('providers');
1951
2068
  setPhase('picker');
1952
2069
  return;
1953
2070
  case 'permission-mode': {
@@ -2007,9 +2124,9 @@ export function App({ ctx: initialCtx }) {
2007
2124
  return;
2008
2125
  case 'logout': {
2009
2126
  const path = credentialsPath();
2010
- const had = readStoredSubscriptionCredential() !== null;
2127
+ const had = readStoredSubscriptionCredential() !== null || readStoredCodexCredential() !== null;
2011
2128
  try {
2012
- clearStoredSubscriptionCredential();
2129
+ clearAllStoredCredentials();
2013
2130
  }
2014
2131
  catch (err) {
2015
2132
  pushMessage('system', `Could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
@@ -2380,30 +2497,11 @@ export function App({ ctx: initialCtx }) {
2380
2497
  pushMessage('system', 'Nothing to copy yet — /copy applies to the latest assistant answer that finished normally.');
2381
2498
  return;
2382
2499
  }
2383
- const result = writeClipboardText(target.text, {
2384
- isTTY: stdout.isTTY,
2385
- write: writeStdout,
2500
+ setSelectedCopy(0);
2501
+ setCopyPicker({
2502
+ targets: copyTargetsForResponse(target.text),
2503
+ provenance: target.provenance,
2386
2504
  });
2387
- switch (result.kind) {
2388
- case 'request-sent':
2389
- pushMessage('system', `Copy request sent for the ${target.provenance === 'persisted'
2390
- ? 'latest persisted assistant output'
2391
- : '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.`);
2392
- break;
2393
- case 'unavailable':
2394
- pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
2395
- break;
2396
- case 'too-large':
2397
- 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.`);
2398
- break;
2399
- case 'write-failed':
2400
- pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
2401
- break;
2402
- default: {
2403
- const exhaustive = result;
2404
- void exhaustive;
2405
- }
2406
- }
2407
2505
  return;
2408
2506
  }
2409
2507
  case 'raw': {
@@ -2498,6 +2596,7 @@ export function App({ ctx: initialCtx }) {
2498
2596
  pushMessage,
2499
2597
  rawOutput,
2500
2598
  resetTranscript,
2599
+ setCopyPicker,
2501
2600
  setReasoningEffort,
2502
2601
  slashCtx,
2503
2602
  startFreshConversation,
@@ -2523,6 +2622,7 @@ export function App({ ctx: initialCtx }) {
2523
2622
  conversationMutationRef.current ||
2524
2623
  exportingRef.current ||
2525
2624
  compactingRef.current ||
2625
+ copyPickerRef.current ||
2526
2626
  goalCommandInFlightRef.current) {
2527
2627
  return;
2528
2628
  }
@@ -2547,6 +2647,7 @@ export function App({ ctx: initialCtx }) {
2547
2647
  conversationMutationRef.current ||
2548
2648
  exportingRef.current ||
2549
2649
  compactingRef.current ||
2650
+ copyPickerRef.current ||
2550
2651
  goalCommandInFlightRef.current ||
2551
2652
  queuedRef.current.length > 0 ||
2552
2653
  !goalActivation.isArmed(sessionId, armed)) {
@@ -2604,6 +2705,7 @@ export function App({ ctx: initialCtx }) {
2604
2705
  conversationMutation,
2605
2706
  session,
2606
2707
  state,
2708
+ copyPicker,
2607
2709
  wakeGoalDriver,
2608
2710
  ]);
2609
2711
  // Drain the queue: when a turn settles (idle) and nothing is running,
@@ -2618,6 +2720,7 @@ export function App({ ctx: initialCtx }) {
2618
2720
  conversationMutationRef.current ||
2619
2721
  exportingRef.current ||
2620
2722
  compactingRef.current ||
2723
+ copyPickerRef.current ||
2621
2724
  goalCommandInFlightRef.current)
2622
2725
  return;
2623
2726
  // Read and remove from the synchronous source of truth. `queued` is the
@@ -2627,7 +2730,16 @@ export function App({ ctx: initialCtx }) {
2627
2730
  const next = dequeueQueued();
2628
2731
  if (next !== undefined)
2629
2732
  void runTurn(next);
2630
- }, [state, phase, queued, queuePause, dequeueQueued, hasUnsettledTurn, runTurn]);
2733
+ }, [
2734
+ state,
2735
+ phase,
2736
+ queued,
2737
+ queuePause,
2738
+ copyPicker,
2739
+ dequeueQueued,
2740
+ hasUnsettledTurn,
2741
+ runTurn,
2742
+ ]);
2631
2743
  // One-shot update check on launch.
2632
2744
  // Best-effort; surfaces a single notice when something newer is out.
2633
2745
  const updateCheckedRef = useRef(false);
@@ -2642,7 +2754,7 @@ export function App({ ctx: initialCtx }) {
2642
2754
  pushMessage('system', `⬆ Update available:\n${lines.join('\n')}`);
2643
2755
  });
2644
2756
  }, [phase, ctx.version, pushMessage]);
2645
- const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal) => {
2757
+ const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal, revealAllOnFailure = false) => {
2646
2758
  try {
2647
2759
  await hydrateSession(prefs, detectedNow, signal);
2648
2760
  }
@@ -2652,6 +2764,11 @@ export function App({ ctx: initialCtx }) {
2652
2764
  // choice stays on the picker with the actionable construction error.
2653
2765
  if (signal.aborted)
2654
2766
  return;
2767
+ if (revealAllOnFailure) {
2768
+ setPickerDetected(null);
2769
+ setPickerSelectionKind('provider-and-model');
2770
+ setPickerInitialView('providers');
2771
+ }
2655
2772
  setPickerNotice(`Could not start the selected provider: ${err instanceof Error ? err.message : String(err)}`);
2656
2773
  }
2657
2774
  }, [hydrateSession]);
@@ -2667,6 +2784,8 @@ export function App({ ctx: initialCtx }) {
2667
2784
  const next = [credential, ...detected.filter((d) => d.entry.id !== credential.entry.id)];
2668
2785
  setDetected(next);
2669
2786
  setKeyEntryFor(null);
2787
+ setPickerDetected(null);
2788
+ setPickerSelectionKind('provider-and-model');
2670
2789
  setPickerNotice(null);
2671
2790
  // The disposition, not the key. `credential` never reaches a message.
2672
2791
  pushMessage('system', disposition);
@@ -2708,7 +2827,9 @@ export function App({ ctx: initialCtx }) {
2708
2827
  pushMessage('system', `Could not save preferences: ${err instanceof Error ? err.message : String(err)}`);
2709
2828
  return;
2710
2829
  }
2711
- void hydrateFromPicker(prefs, detected, signal);
2830
+ // Keep a narrowed signed-in roster stable while construction is pending.
2831
+ // Only a real refusal broadens back to the general, sign-in-capable picker.
2832
+ void hydrateFromPicker(prefs, detected, signal, true);
2712
2833
  }, [detected, hydrateFromPicker, pushMessage]);
2713
2834
  /**
2714
2835
  * Leaving the picker returns to whatever was on screen before it.
@@ -2729,6 +2850,9 @@ export function App({ ctx: initialCtx }) {
2729
2850
  * what the empty picker's own footer has always claimed `esc` does.
2730
2851
  */
2731
2852
  const handlePickerCancel = useCallback(() => {
2853
+ setPickerInitialView('providers');
2854
+ setPickerDetected(null);
2855
+ setPickerSelectionKind('provider-and-model');
2732
2856
  if (session?.hasProvider) {
2733
2857
  setPhase('ready');
2734
2858
  return;
@@ -2854,6 +2978,34 @@ export function App({ ctx: initialCtx }) {
2854
2978
  resolvePermission({ kind: 'approve-all' });
2855
2979
  return;
2856
2980
  }
2981
+ // `/copy` holds a source snapshot, not a lifecycle phase. Permission
2982
+ // stays above it in this handler so an approval request that arrives
2983
+ // while the picker is open cannot have its keys stolen by the chooser.
2984
+ if (copyPickerRef.current) {
2985
+ const targets = copyPickerRef.current.targets;
2986
+ if (key.escape || (key.ctrl && input === 'c')) {
2987
+ setCopyPicker(null);
2988
+ return;
2989
+ }
2990
+ if (key.upArrow) {
2991
+ setSelectedCopy((index) => Math.max(0, index - 1));
2992
+ return;
2993
+ }
2994
+ if (key.downArrow) {
2995
+ setSelectedCopy((index) => Math.min(targets.length - 1, index + 1));
2996
+ return;
2997
+ }
2998
+ if (key.return) {
2999
+ sendCopyRequest(selectedCopy);
3000
+ return;
3001
+ }
3002
+ if (/^[1-9]$/.test(input)) {
3003
+ const index = Number(input) - 1;
3004
+ if (targets[index])
3005
+ sendCopyRequest(index);
3006
+ }
3007
+ return;
3008
+ }
2857
3009
  // Esc interrupts a running turn (Ctrl+C stays reserved for exit). Mirrors
2858
3010
  // the Ctrl+C interrupt path: abort, drop the queue, one "Interrupted." line.
2859
3011
  if (key.escape && abortRef.current) {
@@ -2930,12 +3082,15 @@ export function App({ ctx: initialCtx }) {
2930
3082
  // Background is left natural — we inherit the terminal's own background
2931
3083
  // and only theme the foreground. Forcing
2932
3084
  // a filled bg left mismatched patches around bordered areas, so we don't.
2933
- 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, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: (signal) => void startLoginFromPicker(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
3085
+ 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 })) : copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
3086
+ phase === 'ready' &&
3087
+ conversationMutation === null &&
3088
+ copyPicker === null, hidden: permission !== null || copyPicker !== null, children: [queued.length > 0 && permission === null && 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
2934
3089
  ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
2935
3090
  : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
2936
3091
  state === 'awaiting-permission' ||
2937
3092
  compacting ||
2938
- conversationMutation !== null, hidden: permission !== null,
3093
+ conversationMutation !== null, hidden: permission !== null || copyPicker !== null,
2939
3094
  // A turn is running, so Esc is the interrupt and not
2940
3095
  // the composer's clear.
2941
3096
  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
@@ -2948,7 +3103,11 @@ export function App({ ctx: initialCtx }) {
2948
3103
  ? 'starting a fresh conversation — input is paused'
2949
3104
  : compacting
2950
3105
  ? 'compacting conversation — input is paused'
2951
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
3106
+ : permission
3107
+ ? hintForPhase(phase, state, session?.hasProvider === true)
3108
+ : copyPicker
3109
+ ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
3110
+ : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
2952
3111
  }
2953
3112
  function Banner({ version, session, permissionMode, cwd, }) {
2954
3113
  const cols = process.stdout.columns ?? 80;