privateer-agent 0.2.1 → 0.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 (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,203 +0,0 @@
1
- import React, { useState } from "react";
2
- import { Box, Text, useInput } from "ink";
3
- import TextInput from "ink-text-input";
4
- import type { Config, ProviderName, ProviderConfig } from "../config/schema.ts";
5
- import { PROVIDER_LIST, PROVIDER_META, type ProviderMeta } from "../providers/catalog.ts";
6
- import { providerRequiresKey } from "../providers/registry.ts";
7
- import { ModelPicker } from "./ModelPicker.tsx";
8
- import { theme } from "./theme.ts";
9
- import { WELCOME } from "./figures.ts";
10
-
11
- // Which of the just-entered providers are actually usable (have a key, or are keyless).
12
- function readyProviders(creds: Partial<Record<ProviderName, ProviderConfig>>): ProviderName[] {
13
- return (Object.keys(creds) as ProviderName[]).filter((name) =>
14
- providerRequiresKey(name) ? Boolean(creds[name]?.apiKey) : true,
15
- );
16
- }
17
-
18
- export interface OnboardingResult {
19
- providers: Partial<Record<ProviderName, ProviderConfig>>;
20
- defaultModel: string;
21
- }
22
-
23
- // Step 1 — multi-select the providers to configure. Arrows/jk move, space toggles,
24
- // enter confirms (needs at least one).
25
- function SelectStep({
26
- initial,
27
- onConfirm,
28
- }: {
29
- initial: Set<ProviderName>;
30
- onConfirm: (selected: ProviderMeta[]) => void;
31
- }) {
32
- const [cursor, setCursor] = useState(0);
33
- const [selected, setSelected] = useState<Set<ProviderName>>(new Set(initial));
34
-
35
- useInput((input, key) => {
36
- if (key.upArrow || input === "k") setCursor((c) => (c - 1 + PROVIDER_LIST.length) % PROVIDER_LIST.length);
37
- else if (key.downArrow || input === "j") setCursor((c) => (c + 1) % PROVIDER_LIST.length);
38
- else if (input === " ") {
39
- const name = PROVIDER_LIST[cursor].name;
40
- setSelected((s) => {
41
- const next = new Set(s);
42
- next.has(name) ? next.delete(name) : next.add(name);
43
- return next;
44
- });
45
- } else if (key.return && selected.size > 0) {
46
- onConfirm(PROVIDER_LIST.filter((p) => selected.has(p.name)));
47
- }
48
- });
49
-
50
- return (
51
- <Box flexDirection="column">
52
- <Text color={theme.dim}>
53
- Select the providers you want to use — <Text color={theme.accent}>space</Text> to toggle,{" "}
54
- <Text color={theme.accent}>enter</Text> to continue.
55
- </Text>
56
- <Box flexDirection="column" marginTop={1}>
57
- {PROVIDER_LIST.map((p, i) => {
58
- const on = selected.has(p.name);
59
- const active = i === cursor;
60
- return (
61
- <Text key={p.name} color={active ? theme.accent : undefined}>
62
- {active ? "❯ " : " "}
63
- {on ? "◉" : "○"} {p.label.padEnd(16)}
64
- <Text color={theme.dim}> {p.requiresKey ? `key: ${p.keyHint}` : p.keyHint}</Text>
65
- </Text>
66
- );
67
- })}
68
- </Box>
69
- {selected.size === 0 && (
70
- <Text color={theme.dim}>{"\n"}Select at least one provider to continue.</Text>
71
- )}
72
- </Box>
73
- );
74
- }
75
-
76
- // Step 2 — walk the chosen providers one at a time, collecting a masked API key (or a
77
- // base URL for keyless/local providers). Empty key = skip that provider's credential.
78
- function KeyStep({
79
- providers,
80
- onDone,
81
- }: {
82
- providers: ProviderMeta[];
83
- onDone: (creds: Partial<Record<ProviderName, ProviderConfig>>) => void;
84
- }) {
85
- const [index, setIndex] = useState(0);
86
- const [value, setValue] = useState("");
87
- const [creds, setCreds] = useState<Partial<Record<ProviderName, ProviderConfig>>>({});
88
- const meta = providers[index];
89
-
90
- function submit(raw: string) {
91
- const v = raw.trim();
92
- const entry: ProviderConfig = meta.requiresKey
93
- ? v
94
- ? { apiKey: v }
95
- : {}
96
- : { baseURL: v || meta.baseURLDefault };
97
- const nextCreds = { ...creds, [meta.name]: entry };
98
- setCreds(nextCreds);
99
- setValue("");
100
- if (index + 1 < providers.length) setIndex(index + 1);
101
- else onDone(nextCreds);
102
- }
103
-
104
- return (
105
- <Box flexDirection="column">
106
- <Text color={theme.dim}>
107
- Step {index + 1} of {providers.length} —{" "}
108
- <Text color={theme.accent}>{meta.label}</Text>
109
- </Text>
110
- <Text color={theme.dim}>
111
- {meta.requiresKey
112
- ? `Paste your API key (${meta.keyHint}). Enter to skip.`
113
- : `Base URL for Ollama. Enter to use the default.`}
114
- </Text>
115
- <Box marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
116
- <Text color={theme.accent}>{"> "}</Text>
117
- <TextInput
118
- value={value}
119
- onChange={setValue}
120
- onSubmit={submit}
121
- mask={meta.requiresKey ? "*" : undefined}
122
- placeholder={meta.requiresKey ? "sk-…" : meta.baseURLDefault}
123
- />
124
- </Box>
125
- </Box>
126
- );
127
- }
128
-
129
- // Step 3 — pick the default model from the providers just configured, with the model
130
- // list fetched live using the entered keys. Esc (or a fetch failure) falls back to the
131
- // provider's catalog default, so onboarding always completes.
132
- function ModelStep({
133
- creds,
134
- ready,
135
- fallback,
136
- onDone,
137
- }: {
138
- creds: Partial<Record<ProviderName, ProviderConfig>>;
139
- ready: ProviderName[];
140
- fallback: string;
141
- onDone: (spec: string) => void;
142
- }) {
143
- // ModelPicker only reads config.providers; a synthetic config is enough here.
144
- const synthetic = { providers: creds } as Config;
145
- return (
146
- <Box flexDirection="column">
147
- <Text color={theme.dim}>Choose your default model — you can change it later with /model.</Text>
148
- <Box marginTop={1}>
149
- <ModelPicker
150
- config={synthetic}
151
- providers={ready}
152
- onSelect={onDone}
153
- onCancel={() => onDone(fallback)}
154
- />
155
- </Box>
156
- </Box>
157
- );
158
- }
159
-
160
- export function Onboarding({
161
- initialSelected = [],
162
- onComplete,
163
- }: {
164
- initialSelected?: ProviderName[];
165
- onComplete: (result: OnboardingResult) => void;
166
- }) {
167
- const [chosen, setChosen] = useState<ProviderMeta[] | null>(null);
168
- const [creds, setCreds] = useState<Partial<Record<ProviderName, ProviderConfig>> | null>(null);
169
-
170
- function onKeysDone(entered: Partial<Record<ProviderName, ProviderConfig>>) {
171
- const ready = readyProviders(entered);
172
- // No usable credentials (every key skipped): nothing to pick from — finish on the
173
- // chosen provider's catalog default rather than showing an empty picker.
174
- if (ready.length === 0) {
175
- onComplete({ providers: entered, defaultModel: chosen![0].defaultModel });
176
- return;
177
- }
178
- setCreds(entered);
179
- }
180
-
181
- return (
182
- <Box flexDirection="column" paddingX={1} paddingTop={1}>
183
- <Text bold color={theme.accent}>
184
- {WELCOME} Welcome to Privateer — let's set up your providers
185
- </Text>
186
- <Text color={theme.dim}>Bring your own keys. They're saved to ~/.privateer/config.json.</Text>
187
- <Box marginTop={1}>
188
- {chosen === null ? (
189
- <SelectStep initial={new Set(initialSelected)} onConfirm={setChosen} />
190
- ) : creds === null ? (
191
- <KeyStep providers={chosen} onDone={onKeysDone} />
192
- ) : (
193
- <ModelStep
194
- creds={creds}
195
- ready={readyProviders(creds)}
196
- fallback={PROVIDER_META[readyProviders(creds)[0]].defaultModel}
197
- onDone={(spec) => onComplete({ providers: creds, defaultModel: spec })}
198
- />
199
- )}
200
- </Box>
201
- </Box>
202
- );
203
- }
@@ -1,134 +0,0 @@
1
- import React, { useState } from "react";
2
- import { Box, Text, useInput } from "ink";
3
- import type { UserQuestion, UserAnswer } from "../tools/askUser.ts";
4
- import { theme } from "./theme.ts";
5
-
6
- // Interactive picker shown when the agent calls `ask_user` to choose between
7
- // competing approaches. The turn is blocked on the human (like the approval prompt).
8
- // Keys: ↑/↓ move, 1–N jump, Enter confirm, e (or the last row) write a custom answer,
9
- // Esc dismiss. In multiSelect mode, Space/number toggles and Enter confirms the set.
10
- export function OptionPicker({
11
- question,
12
- onRespond,
13
- }: {
14
- question: UserQuestion;
15
- onRespond: (answer: UserAnswer) => void;
16
- }) {
17
- const { options, multiSelect } = question;
18
- const otherIndex = options.length; // the trailing "write your own" row
19
- const total = options.length + 1;
20
- const [cursor, setCursor] = useState(0);
21
- const [checked, setChecked] = useState<Set<number>>(new Set());
22
- const [mode, setMode] = useState<"list" | "custom">("list");
23
- const [draft, setDraft] = useState("");
24
-
25
- function toggle(i: number) {
26
- setChecked((prev) => {
27
- const next = new Set(prev);
28
- if (next.has(i)) next.delete(i);
29
- else next.add(i);
30
- return next;
31
- });
32
- }
33
-
34
- function confirm() {
35
- if (cursor === otherIndex) {
36
- setMode("custom");
37
- return;
38
- }
39
- if (multiSelect) {
40
- const picks = checked.size > 0 ? [...checked] : [cursor];
41
- onRespond({ kind: "selected", indices: picks.sort((a, b) => a - b) });
42
- } else {
43
- onRespond({ kind: "selected", indices: [cursor] });
44
- }
45
- }
46
-
47
- useInput((input, key) => {
48
- if (mode === "custom") {
49
- if (key.escape) {
50
- setMode("list");
51
- setDraft("");
52
- } else if (key.return) {
53
- const text = draft.trim();
54
- if (text) onRespond({ kind: "custom", text });
55
- } else if (key.backspace || key.delete) {
56
- setDraft((d) => d.slice(0, -1));
57
- } else if (input && !key.ctrl && !key.meta) {
58
- setDraft((d) => d + input);
59
- }
60
- return;
61
- }
62
-
63
- if (key.upArrow) setCursor((c) => (c - 1 + total) % total);
64
- else if (key.downArrow) setCursor((c) => (c + 1) % total);
65
- else if (key.escape) onRespond({ kind: "dismissed" });
66
- else if (input === "e") setMode("custom");
67
- else if (input >= "1" && input <= "9") {
68
- const i = Number(input) - 1;
69
- if (i < options.length) {
70
- if (multiSelect) {
71
- setCursor(i);
72
- toggle(i);
73
- } else {
74
- onRespond({ kind: "selected", indices: [i] });
75
- }
76
- }
77
- } else if (input === " " && multiSelect && cursor < options.length) {
78
- toggle(cursor);
79
- } else if (key.return) {
80
- confirm();
81
- }
82
- });
83
-
84
- if (mode === "custom") {
85
- return (
86
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
87
- <Text bold color={theme.accent}>
88
- {question.question}
89
- </Text>
90
- <Text>
91
- <Text color={theme.dim}>your answer: </Text>
92
- {draft}
93
- <Text color={theme.accent}>▏</Text>
94
- </Text>
95
- <Text dimColor>enter submit · esc back to options</Text>
96
- </Box>
97
- );
98
- }
99
-
100
- return (
101
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
102
- <Text bold color={theme.accent}>
103
- {question.question}
104
- </Text>
105
- {options.map((o, i) => {
106
- const active = i === cursor;
107
- const box = multiSelect ? (checked.has(i) ? "[x] " : "[ ] ") : "";
108
- return (
109
- <Box key={i} flexDirection="column" marginTop={1}>
110
- <Text color={active ? theme.accent : undefined}>
111
- <Text color={active ? theme.accent : theme.dim}>{active ? "❯ " : " "}</Text>
112
- <Text color={theme.dim}>{i + 1}. </Text>
113
- {box}
114
- <Text bold={active}>{o.label}</Text>
115
- </Text>
116
- {o.description ? <Text dimColor>{" " + o.description}</Text> : null}
117
- </Box>
118
- );
119
- })}
120
- <Box marginTop={1}>
121
- <Text color={cursor === otherIndex ? theme.accent : undefined}>
122
- <Text color={cursor === otherIndex ? theme.accent : theme.dim}>{cursor === otherIndex ? "❯ " : " "}</Text>
123
- <Text color={theme.dim}>e. </Text>
124
- <Text bold={cursor === otherIndex}>Something else — write your own answer</Text>
125
- </Text>
126
- </Box>
127
- <Text dimColor>
128
- {multiSelect
129
- ? "↑/↓ move · space toggle · enter confirm · esc skip"
130
- : "↑/↓ move · 1–" + options.length + " pick · enter confirm · esc skip"}
131
- </Text>
132
- </Box>
133
- );
134
- }
@@ -1,37 +0,0 @@
1
- import React from "react";
2
- import { Box, Text, useInput } from "ink";
3
- import { theme } from "./theme.ts";
4
-
5
- // Shown after a turn completes while in plan mode: the agent has presented a plan,
6
- // and the user chooses to approve (leave plan mode), keep planning, or chat about
7
- // the plan (return to the prompt, staying in plan mode, to ask questions).
8
- export function PlanConfirm({
9
- onApprove,
10
- onKeep,
11
- onChat,
12
- }: {
13
- onApprove: () => void;
14
- onKeep: () => void;
15
- onChat: () => void;
16
- }) {
17
- useInput((input, key) => {
18
- if (input === "a" || key.return) onApprove();
19
- else if (input === "c") onChat();
20
- else if (input === "k" || key.escape) onKeep();
21
- });
22
- return (
23
- <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
24
- <Text color={theme.accent}>Plan ready — review it above.</Text>
25
- <Text>
26
- <Text color={theme.accent}>a</Text>
27
- <Text color={theme.dim}> approve & exit plan mode</Text>
28
- <Text color={theme.dim}>{" "}</Text>
29
- <Text color={theme.accent}>c</Text>
30
- <Text color={theme.dim}> chat about the plan</Text>
31
- <Text color={theme.dim}>{" "}</Text>
32
- <Text color={theme.accent}>k</Text>
33
- <Text color={theme.dim}> keep planning</Text>
34
- </Text>
35
- </Box>
36
- );
37
- }
@@ -1,109 +0,0 @@
1
- import React, { useEffect, useRef, useState } from "react";
2
- import { Box, Text, useInput } from "ink";
3
- import Spinner from "ink-spinner";
4
- import { theme } from "./theme.ts";
5
- import { WELCOME } from "./figures.ts";
6
- import { runDeviceLogin, type DeviceCode, type PrivateerUser } from "../auth/privateer.ts";
7
-
8
- // Device-authorization login screen. Shows the user_code to approve in the
9
- // Privateer app, polls in the background, and resolves once approved. Esc
10
- // cancels. Works the same for email and wallet accounts — all the signing
11
- // happens in the app, never here.
12
- export function PrivateerLogin({
13
- onComplete,
14
- onCancel,
15
- }: {
16
- onComplete: (user: PrivateerUser) => void;
17
- onCancel: () => void;
18
- }) {
19
- const [code, setCode] = useState<DeviceCode | null>(null);
20
- const [error, setError] = useState<string | null>(null);
21
- const [slow, setSlow] = useState(false);
22
- const abortRef = useRef<AbortController | null>(null);
23
-
24
- useEffect(() => {
25
- const ac = new AbortController();
26
- abortRef.current = ac;
27
- let cancelled = false;
28
-
29
- runDeviceLogin({
30
- onCode: (c) => !cancelled && setCode(c),
31
- onPoll: (state) => !cancelled && setSlow(state === "slow_down"),
32
- signal: ac.signal,
33
- })
34
- .then((user) => {
35
- if (!cancelled) onComplete(user);
36
- })
37
- .catch((err) => {
38
- if (cancelled) return;
39
- if (ac.signal.aborted) return; // user cancelled — handled by useInput
40
- setError(err instanceof Error ? err.message : String(err));
41
- });
42
-
43
- return () => {
44
- cancelled = true;
45
- ac.abort();
46
- };
47
- // eslint-disable-next-line react-hooks/exhaustive-deps
48
- }, []);
49
-
50
- useInput((_input, key) => {
51
- if (key.escape || (key.return && error)) {
52
- abortRef.current?.abort();
53
- onCancel();
54
- }
55
- });
56
-
57
- return (
58
- <Box flexDirection="column" paddingX={1} paddingTop={1}>
59
- <Text bold color={theme.accent}>
60
- {WELCOME} Sign in to your Privateer account
61
- </Text>
62
- <Text color={theme.dim}>
63
- Inference runs on your account and is billed to your subscription — no API key needed.
64
- </Text>
65
-
66
- {error ? (
67
- <Box flexDirection="column" marginTop={1}>
68
- <Text color={theme.error ?? "red"}>{error}</Text>
69
- <Text color={theme.dim}>Press Esc or Enter to go back.</Text>
70
- </Box>
71
- ) : !code ? (
72
- <Box marginTop={1}>
73
- <Text color={theme.accent}>
74
- <Spinner type="dots" />
75
- </Text>
76
- <Text color={theme.dim}> Starting secure login…</Text>
77
- </Box>
78
- ) : (
79
- <Box flexDirection="column" marginTop={1}>
80
- <Text color={theme.dim}>1. Open the Privateer app (or web) where you're signed in.</Text>
81
- <Text color={theme.dim}>
82
- 2. Go to <Text color={theme.accent}>Settings → Link a terminal</Text>
83
- {code.verification_uri ? (
84
- <Text color={theme.dim}>
85
- {" "}
86
- ({code.verification_uri})
87
- </Text>
88
- ) : null}
89
- </Text>
90
- <Text color={theme.dim}>3. Enter this code (or just open the link above):</Text>
91
- <Box marginY={1} borderStyle="round" borderColor={theme.accent} paddingX={2}>
92
- <Text bold color={theme.accent}>
93
- {code.user_code}
94
- </Text>
95
- </Box>
96
- {/* Deliberately NOT an animated spinner here: ink-spinner re-renders the
97
- whole frame ~12×/s, which wipes any text selection on the code above
98
- and makes it almost impossible to copy. A static line keeps the code
99
- selectable; this view only re-renders on a real state change. */}
100
- <Box>
101
- <Text color={theme.dim}>
102
- Waiting for approval…{slow ? " (slowing down)" : ""} · Esc to cancel
103
- </Text>
104
- </Box>
105
- </Box>
106
- )}
107
- </Box>
108
- );
109
- }