@cdoing/cli 0.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 (118) hide show
  1. package/.cdoing/permissions.json +8 -0
  2. package/dist/callbacks.d.ts +17 -0
  3. package/dist/callbacks.d.ts.map +1 -0
  4. package/dist/callbacks.js +265 -0
  5. package/dist/callbacks.js.map +1 -0
  6. package/dist/chat.d.ts +27 -0
  7. package/dist/chat.d.ts.map +1 -0
  8. package/dist/chat.js +57 -0
  9. package/dist/chat.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +452 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/config.d.ts +84 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +427 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/help.d.ts +9 -0
  19. package/dist/help.d.ts.map +1 -0
  20. package/dist/help.js +167 -0
  21. package/dist/help.js.map +1 -0
  22. package/dist/history.d.ts +51 -0
  23. package/dist/history.d.ts.map +1 -0
  24. package/dist/history.js +207 -0
  25. package/dist/history.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +220 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/oauth.d.ts +13 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +182 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/review.d.ts +26 -0
  35. package/dist/review.d.ts.map +1 -0
  36. package/dist/review.js +198 -0
  37. package/dist/review.js.map +1 -0
  38. package/dist/serve.d.ts +23 -0
  39. package/dist/serve.d.ts.map +1 -0
  40. package/dist/serve.js +293 -0
  41. package/dist/serve.js.map +1 -0
  42. package/dist/tools.d.ts +14 -0
  43. package/dist/tools.d.ts.map +1 -0
  44. package/dist/tools.js +57 -0
  45. package/dist/tools.js.map +1 -0
  46. package/dist/ui/App.d.ts +24 -0
  47. package/dist/ui/App.d.ts.map +1 -0
  48. package/dist/ui/App.js +321 -0
  49. package/dist/ui/App.js.map +1 -0
  50. package/dist/ui/MessageList.d.ts +14 -0
  51. package/dist/ui/MessageList.d.ts.map +1 -0
  52. package/dist/ui/MessageList.js +147 -0
  53. package/dist/ui/MessageList.js.map +1 -0
  54. package/dist/ui/SessionBrowser.d.ts +18 -0
  55. package/dist/ui/SessionBrowser.d.ts.map +1 -0
  56. package/dist/ui/SessionBrowser.js +149 -0
  57. package/dist/ui/SessionBrowser.js.map +1 -0
  58. package/dist/ui/SetupWizard.d.ts +23 -0
  59. package/dist/ui/SetupWizard.d.ts.map +1 -0
  60. package/dist/ui/SetupWizard.js +402 -0
  61. package/dist/ui/SetupWizard.js.map +1 -0
  62. package/dist/ui/Spinner.d.ts +15 -0
  63. package/dist/ui/Spinner.d.ts.map +1 -0
  64. package/dist/ui/Spinner.js +111 -0
  65. package/dist/ui/Spinner.js.map +1 -0
  66. package/dist/ui/StatusBar.d.ts +16 -0
  67. package/dist/ui/StatusBar.d.ts.map +1 -0
  68. package/dist/ui/StatusBar.js +56 -0
  69. package/dist/ui/StatusBar.js.map +1 -0
  70. package/dist/ui/UserInput.d.ts +13 -0
  71. package/dist/ui/UserInput.d.ts.map +1 -0
  72. package/dist/ui/UserInput.js +872 -0
  73. package/dist/ui/UserInput.js.map +1 -0
  74. package/dist/ui/hooks/helpers.d.ts +55 -0
  75. package/dist/ui/hooks/helpers.d.ts.map +1 -0
  76. package/dist/ui/hooks/helpers.js +304 -0
  77. package/dist/ui/hooks/helpers.js.map +1 -0
  78. package/dist/ui/hooks/useAgent.d.ts +60 -0
  79. package/dist/ui/hooks/useAgent.d.ts.map +1 -0
  80. package/dist/ui/hooks/useAgent.js +213 -0
  81. package/dist/ui/hooks/useAgent.js.map +1 -0
  82. package/dist/ui/hooks/useChat.d.ts +74 -0
  83. package/dist/ui/hooks/useChat.d.ts.map +1 -0
  84. package/dist/ui/hooks/useChat.js +819 -0
  85. package/dist/ui/hooks/useChat.js.map +1 -0
  86. package/dist/ui/theme.d.ts +73 -0
  87. package/dist/ui/theme.d.ts.map +1 -0
  88. package/dist/ui/theme.js +214 -0
  89. package/dist/ui/theme.js.map +1 -0
  90. package/dist/ui/types.d.ts +37 -0
  91. package/dist/ui/types.d.ts.map +1 -0
  92. package/dist/ui/types.js +3 -0
  93. package/dist/ui/types.js.map +1 -0
  94. package/package.json +33 -0
  95. package/src/callbacks.ts +294 -0
  96. package/src/chat.ts +72 -0
  97. package/src/commands.ts +425 -0
  98. package/src/config.ts +462 -0
  99. package/src/help.ts +182 -0
  100. package/src/history.ts +205 -0
  101. package/src/index.ts +248 -0
  102. package/src/oauth.ts +164 -0
  103. package/src/review.ts +233 -0
  104. package/src/serve.ts +290 -0
  105. package/src/tools.ts +104 -0
  106. package/src/ui/App.tsx +426 -0
  107. package/src/ui/MessageList.tsx +222 -0
  108. package/src/ui/SessionBrowser.tsx +161 -0
  109. package/src/ui/SetupWizard.tsx +412 -0
  110. package/src/ui/Spinner.tsx +103 -0
  111. package/src/ui/StatusBar.tsx +106 -0
  112. package/src/ui/UserInput.tsx +954 -0
  113. package/src/ui/hooks/helpers.ts +271 -0
  114. package/src/ui/hooks/useAgent.ts +270 -0
  115. package/src/ui/hooks/useChat.ts +943 -0
  116. package/src/ui/theme.ts +326 -0
  117. package/src/ui/types.ts +41 -0
  118. package/tsconfig.json +18 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * SessionBrowser — Interactive TUI for browsing and selecting conversations.
3
+ *
4
+ * Triggered by /ls in chat or `cdoing ls` CLI command.
5
+ * Arrow keys to navigate, Enter to load, d to delete, f to fork, Esc to close.
6
+ */
7
+
8
+ import React, { useState, useCallback } from "react";
9
+ import { Box, Text, useInput } from "ink";
10
+ import type { Conversation } from "../history";
11
+ import { getTheme } from "./theme";
12
+
13
+ interface SessionBrowserProps {
14
+ conversations: Conversation[];
15
+ onSelect: (id: string) => void;
16
+ onDelete: (id: string) => void;
17
+ onFork: (id: string) => void;
18
+ onClose: () => void;
19
+ }
20
+
21
+ function formatDate(ts: number): string {
22
+ const d = new Date(ts);
23
+ const now = new Date();
24
+ const diffMs = now.getTime() - d.getTime();
25
+ const diffMins = Math.floor(diffMs / 60000);
26
+ const diffHours = Math.floor(diffMs / 3600000);
27
+ const diffDays = Math.floor(diffMs / 86400000);
28
+
29
+ if (diffMins < 1) return "just now";
30
+ if (diffMins < 60) return `${diffMins}m ago`;
31
+ if (diffHours < 24) return `${diffHours}h ago`;
32
+ if (diffDays < 7) return `${diffDays}d ago`;
33
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
34
+ }
35
+
36
+ export const SessionBrowser: React.FC<SessionBrowserProps> = ({
37
+ conversations,
38
+ onSelect,
39
+ onDelete,
40
+ onFork,
41
+ onClose,
42
+ }) => {
43
+ const [selectedIdx, setSelectedIdx] = useState(0);
44
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
45
+
46
+ const termHeight = (process.stdout.rows || 24) - 8;
47
+ const maxVisible = Math.max(5, termHeight);
48
+
49
+ // Scroll window
50
+ const scrollOffset = Math.max(0, selectedIdx - Math.floor(maxVisible / 2));
51
+ const visible = conversations.slice(scrollOffset, scrollOffset + maxVisible);
52
+
53
+ useInput(useCallback((char, key) => {
54
+ if (confirmDelete) {
55
+ if (char === "y" || char === "Y") {
56
+ onDelete(confirmDelete);
57
+ setConfirmDelete(null);
58
+ setSelectedIdx((i) => Math.min(i, Math.max(0, conversations.length - 2)));
59
+ } else {
60
+ setConfirmDelete(null);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (key.escape) { onClose(); return; }
66
+
67
+ if (key.upArrow || (key.ctrl && char === "p")) {
68
+ setSelectedIdx((i) => Math.max(0, i - 1));
69
+ return;
70
+ }
71
+ if (key.downArrow || (key.ctrl && char === "n")) {
72
+ setSelectedIdx((i) => Math.min(conversations.length - 1, i + 1));
73
+ return;
74
+ }
75
+ if (key.return && conversations[selectedIdx]) {
76
+ onSelect(conversations[selectedIdx].id);
77
+ return;
78
+ }
79
+ if (char === "d" && conversations[selectedIdx]) {
80
+ setConfirmDelete(conversations[selectedIdx].id);
81
+ return;
82
+ }
83
+ if (char === "f" && conversations[selectedIdx]) {
84
+ onFork(conversations[selectedIdx].id);
85
+ return;
86
+ }
87
+ }, [confirmDelete, conversations, selectedIdx, onSelect, onDelete, onFork, onClose]));
88
+
89
+ const t = getTheme();
90
+
91
+ if (conversations.length === 0) {
92
+ return (
93
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
94
+ <Text color={t.accent} bold> 📚 Session Browser</Text>
95
+ <Text dimColor={t.useDim} color={t.textDim}> No saved conversations.</Text>
96
+ <Text dimColor={t.useDim} color={t.textDim}> Press Esc to close.</Text>
97
+ </Box>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
103
+ {/* Header */}
104
+ <Box>
105
+ <Text color={t.accent} bold> 📚 Sessions </Text>
106
+ <Text dimColor={t.useDim} color={t.textDim}>{`(${conversations.length} total)`}</Text>
107
+ </Box>
108
+ <Text dimColor={t.useDim} color={t.textDim}> {"─".repeat(60)}</Text>
109
+
110
+ {/* Confirm delete */}
111
+ {confirmDelete ? (
112
+ <Box paddingLeft={2}>
113
+ <Text color={t.error}>Delete this session? </Text>
114
+ <Text color={t.warning}>[y/N] </Text>
115
+ </Box>
116
+ ) : null}
117
+
118
+ {/* Session list */}
119
+ {visible.map((conv, i) => {
120
+ const globalIdx = scrollOffset + i;
121
+ const isSelected = globalIdx === selectedIdx;
122
+ const msgCount = conv.messages.filter((m) => m.role === "user").length;
123
+ const date = formatDate(conv.updatedAt);
124
+ const title = conv.title.length > 42 ? conv.title.substring(0, 39) + "…" : conv.title;
125
+
126
+ return (
127
+ <Box key={conv.id} paddingLeft={2}>
128
+ {isSelected ? (
129
+ <Box>
130
+ <Text color={t.accent} bold>{"▶ "}</Text>
131
+ <Text backgroundColor={t.selectedBg} color={t.selected === "white" ? "black" : t.selected}>{` ${title.padEnd(42)} `}</Text>
132
+ <Text color={t.sessionDate}>{` ${date.padEnd(8)} `}</Text>
133
+ <Text dimColor={t.useDim} color={t.textDim}>{`${msgCount}msg`}</Text>
134
+ </Box>
135
+ ) : (
136
+ <Box>
137
+ <Text dimColor={t.useDim} color={t.textDim}>{" "}</Text>
138
+ <Text color={t.sessionTitle}>{` ${title.padEnd(42)} `}</Text>
139
+ <Text dimColor={t.useDim} color={t.textDim}>{` ${date.padEnd(8)} `}</Text>
140
+ <Text dimColor={t.useDim} color={t.textDim}>{`${msgCount}msg`}</Text>
141
+ </Box>
142
+ )}
143
+ </Box>
144
+ );
145
+ })}
146
+
147
+ {/* Scroll indicator */}
148
+ {conversations.length > maxVisible ? (
149
+ <Box paddingLeft={2}>
150
+ <Text dimColor={t.useDim} color={t.textDim}>
151
+ {` ${scrollOffset + 1}–${Math.min(scrollOffset + maxVisible, conversations.length)} of ${conversations.length}`}
152
+ </Text>
153
+ </Box>
154
+ ) : null}
155
+
156
+ {/* Footer controls */}
157
+ <Text dimColor={t.useDim} color={t.textDim}> {"─".repeat(60)}</Text>
158
+ <Text dimColor={t.useDim} color={t.textDim}>{" ↑/↓ navigate Enter=load f=fork d=delete Esc=close"}</Text>
159
+ </Box>
160
+ );
161
+ };
@@ -0,0 +1,412 @@
1
+ /**
2
+ * SetupWizard — interactive in-TUI wizard for provider / model / API key / OAuth.
3
+ * Triggered by /setup. Arrow keys navigate, Enter selects, Esc cancels.
4
+ *
5
+ * Flow:
6
+ * Anthropic → 1. Provider 2. Auth method 3. Model (filtered) 4. API key or OAuth
7
+ * Others → 1. Provider 2. Model 3. API key
8
+ * Ollama → 1. Provider 2. Model (no key needed)
9
+ */
10
+
11
+ import React, { useState, useCallback, useEffect, useRef } from "react";
12
+ import { Box, Text, useInput } from "ink";
13
+ import { loadConfig, saveConfig } from "../config";
14
+ import { generateOAuthUrl, exchangeOAuthCode } from "../oauth";
15
+ import { getTheme } from "./theme";
16
+
17
+ // ── Static data ───────────────────────────────────────────────────────────────
18
+
19
+ const PROVIDERS = [
20
+ { value: "anthropic", label: "Anthropic (Claude)", hint: "claude-sonnet-4-6, claude-opus-4-6" },
21
+ { value: "openai", label: "OpenAI (GPT)", hint: "gpt-4o, gpt-4o-mini" },
22
+ { value: "google", label: "Google (Gemini)", hint: "gemini-2.0-flash, gemini-1.5-pro" },
23
+ { value: "ollama", label: "Ollama (local)", hint: "llama3.1, mistral, codellama" },
24
+ ];
25
+
26
+ const AUTH_METHODS = [
27
+ { value: "apikey", label: "API key", hint: "all models available · console.anthropic.com" },
28
+ { value: "oauth", label: "OAuth", hint: "Claude Pro/Max · opens browser" },
29
+ ];
30
+
31
+ // All models per provider
32
+ const ALL_MODELS: Record<string, { value: string; label: string; hint: string }[]> = {
33
+ anthropic: [
34
+ { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", hint: "recommended · fast & smart" },
35
+ { value: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "most capable" },
36
+ { value: "claude-haiku-4-5", label: "Claude Haiku 4.5", hint: "fastest" },
37
+ ],
38
+ openai: [
39
+ { value: "gpt-4o", label: "GPT-4o", hint: "recommended" },
40
+ { value: "gpt-4o-mini", label: "GPT-4o mini", hint: "fastest · cheapest" },
41
+ { value: "o3-mini", label: "o3-mini", hint: "reasoning" },
42
+ ],
43
+ google: [
44
+ { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", hint: "recommended · fast" },
45
+ { value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", hint: "most capable" },
46
+ { value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", hint: "fastest" },
47
+ ],
48
+ ollama: [
49
+ { value: "llama3.1", label: "LLaMA 3.1", hint: "general purpose" },
50
+ { value: "mistral", label: "Mistral", hint: "fast & capable" },
51
+ { value: "codellama", label: "CodeLlama", hint: "code-focused" },
52
+ { value: "phi3", label: "Phi-3", hint: "small & fast" },
53
+ ],
54
+ };
55
+
56
+ // OAuth only supports Haiku currently
57
+ const OAUTH_MODELS = [
58
+ { value: "claude-haiku-4-5", label: "Claude Haiku 4.5", hint: "only model supported with OAuth" },
59
+ ];
60
+
61
+ const KEY_URLS: Record<string, string> = {
62
+ anthropic: "https://console.anthropic.com/settings/keys",
63
+ openai: "https://platform.openai.com/api-keys",
64
+ google: "https://aistudio.google.com/apikey",
65
+ };
66
+
67
+ type Step =
68
+ | "provider"
69
+ | "auth-method"
70
+ | "model"
71
+ | "apikey"
72
+ | "oauth-paste"
73
+ | "oauth-exchanging"
74
+ | "done";
75
+
76
+ // ── Menu sub-component ────────────────────────────────────────────────────────
77
+
78
+ interface MenuProps<T extends { value: string; label: string; hint?: string }> {
79
+ title: string;
80
+ items: T[];
81
+ selectedIdx: number;
82
+ }
83
+
84
+ function Menu<T extends { value: string; label: string; hint?: string }>({
85
+ title,
86
+ items,
87
+ selectedIdx,
88
+ }: MenuProps<T>) {
89
+ const t = getTheme();
90
+ return (
91
+ <Box flexDirection="column" paddingLeft={2}>
92
+ <Text color={t.accent} bold>{title}</Text>
93
+ <Text dimColor={t.useDim} color={t.textDim}>{"─".repeat(50)}</Text>
94
+ {items.map((item, i) => {
95
+ const isSelected = i === selectedIdx;
96
+ return (
97
+ <Box key={item.value}>
98
+ {isSelected
99
+ ? <Text color={t.accent} bold>{" ❯ "}</Text>
100
+ : <Text dimColor={t.useDim} color={t.textDim}>{" "}</Text>}
101
+ {isSelected
102
+ ? <Text color={t.text} bold>{item.label}</Text>
103
+ : <Text color={t.text}>{item.label}</Text>}
104
+ {item.hint
105
+ ? <Text dimColor={t.useDim} color={t.textDim}>{` ${item.hint}`}</Text>
106
+ : null}
107
+ </Box>
108
+ );
109
+ })}
110
+ <Text dimColor={t.useDim} color={t.textDim}>{"─".repeat(50)}</Text>
111
+ <Text dimColor={t.useDim} color={t.textDim}>{"↑/↓ navigate Enter select Esc cancel"}</Text>
112
+ </Box>
113
+ );
114
+ }
115
+
116
+ // ── Main component ────────────────────────────────────────────────────────────
117
+
118
+ export interface SetupWizardProps {
119
+ currentProvider: string;
120
+ currentModel: string;
121
+ onDone: (result: { provider: string; model: string; apiKey?: string; oauthToken?: string }) => void;
122
+ onCancel: () => void;
123
+ }
124
+
125
+ export const SetupWizard: React.FC<SetupWizardProps> = ({
126
+ currentProvider,
127
+ currentModel,
128
+ onDone,
129
+ onCancel,
130
+ }) => {
131
+ const initProviderIdx = Math.max(0, PROVIDERS.findIndex(p => p.value === currentProvider));
132
+
133
+ const [step, setStep] = useState<Step>("provider");
134
+ const [providerIdx, setProviderIdx] = useState(initProviderIdx);
135
+ const [authIdx, setAuthIdx] = useState(0);
136
+ const [modelIdx, setModelIdx] = useState(0);
137
+ const [chosenProvider, setChosenProvider] = useState(currentProvider);
138
+ const [chosenAuthMethod, setChosenAuthMethod] = useState<"apikey" | "oauth">("apikey");
139
+ const [chosenModel, setChosenModel] = useState(currentModel);
140
+ // API key step
141
+ const [apiKeyInput, setApiKeyInput] = useState("");
142
+ const [showKey, setShowKey] = useState(false);
143
+ // OAuth paste step
144
+ const [oauthUrl, setOauthUrl] = useState("");
145
+ const [oauthVerifier, setOauthVerifier] = useState("");
146
+ const [oauthCodeInput, setOauthCodeInput] = useState("");
147
+ const [showCode, setShowCode] = useState(false);
148
+ const [oauthError, setOauthError] = useState("");
149
+ const exchangingRef = useRef(false);
150
+
151
+ // Generate OAuth URL and open browser when entering oauth-paste step
152
+ useEffect(() => {
153
+ if (step !== "oauth-paste") return;
154
+ const { url, codeVerifier } = generateOAuthUrl();
155
+ setOauthUrl(url);
156
+ setOauthVerifier(codeVerifier);
157
+ setOauthCodeInput("");
158
+ setOauthError("");
159
+ openBrowser(url);
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, [step]);
162
+
163
+ // Returns the model list for the current provider + auth selection
164
+ const getModels = useCallback((provider: string, authMethod: "apikey" | "oauth") => {
165
+ if (provider === "anthropic" && authMethod === "oauth") return OAUTH_MODELS;
166
+ return ALL_MODELS[provider] || [];
167
+ }, []);
168
+
169
+ useInput(useCallback((char, key) => {
170
+ if (key.escape) { onCancel(); return; }
171
+
172
+ // ── 1. Provider ───────────────────────────────────────────────────────
173
+ if (step === "provider") {
174
+ if (key.upArrow) { setProviderIdx(i => Math.max(0, i - 1)); return; }
175
+ if (key.downArrow) { setProviderIdx(i => Math.min(PROVIDERS.length - 1, i + 1)); return; }
176
+ if (key.return) {
177
+ const p = PROVIDERS[providerIdx].value;
178
+ setChosenProvider(p);
179
+ setAuthIdx(0);
180
+ if (p === "anthropic") {
181
+ setStep("auth-method");
182
+ } else {
183
+ // Non-Anthropic: skip auth-method, go straight to model
184
+ const models = ALL_MODELS[p] || [];
185
+ setModelIdx(Math.max(0, models.findIndex(m => m.value === currentModel)));
186
+ setStep("model");
187
+ }
188
+ return;
189
+ }
190
+ }
191
+
192
+ // ── 2. Auth method (Anthropic only) ───────────────────────────────────
193
+ if (step === "auth-method") {
194
+ if (key.upArrow) { setAuthIdx(i => Math.max(0, i - 1)); return; }
195
+ if (key.downArrow) { setAuthIdx(i => Math.min(AUTH_METHODS.length - 1, i + 1)); return; }
196
+ if (key.return) {
197
+ const auth = AUTH_METHODS[authIdx].value as "apikey" | "oauth";
198
+ setChosenAuthMethod(auth);
199
+ const models = getModels("anthropic", auth);
200
+ // Pre-select current model if available, else 0
201
+ setModelIdx(Math.max(0, models.findIndex(m => m.value === currentModel)));
202
+ setStep("model");
203
+ return;
204
+ }
205
+ }
206
+
207
+ // ── 3. Model ──────────────────────────────────────────────────────────
208
+ if (step === "model") {
209
+ const models = getModels(chosenProvider, chosenAuthMethod);
210
+ if (key.upArrow) { setModelIdx(i => Math.max(0, i - 1)); return; }
211
+ if (key.downArrow) { setModelIdx(i => Math.min(models.length - 1, i + 1)); return; }
212
+ if (key.return) {
213
+ const m = models[modelIdx]?.value || "";
214
+ setChosenModel(m);
215
+ if (chosenProvider === "ollama") {
216
+ _save(chosenProvider, m, undefined);
217
+ onDone({ provider: chosenProvider, model: m });
218
+ setStep("done");
219
+ } else if (chosenAuthMethod === "oauth") {
220
+ exchangingRef.current = false;
221
+ setStep("oauth-paste");
222
+ } else {
223
+ setStep("apikey");
224
+ }
225
+ return;
226
+ }
227
+ }
228
+
229
+ // ── 4a. API key entry ─────────────────────────────────────────────────
230
+ if (step === "apikey") {
231
+ if (key.return) {
232
+ const trimmed = apiKeyInput.trim();
233
+ _save(chosenProvider, chosenModel, trimmed || undefined);
234
+ onDone({ provider: chosenProvider, model: chosenModel, apiKey: trimmed || undefined });
235
+ setStep("done");
236
+ return;
237
+ }
238
+ if (key.backspace || key.delete) { setApiKeyInput(k => k.slice(0, -1)); return; }
239
+ if (key.ctrl && char === "s") { setShowKey(v => !v); return; }
240
+ if (char && !key.ctrl && !key.meta) { setApiKeyInput(k => k + char); return; }
241
+ }
242
+
243
+ // ── 4b. OAuth paste ───────────────────────────────────────────────────
244
+ if (step === "oauth-paste") {
245
+ if (key.return) {
246
+ const code = oauthCodeInput.trim();
247
+ if (!code || exchangingRef.current) return;
248
+ exchangingRef.current = true;
249
+ setStep("oauth-exchanging");
250
+ exchangeOAuthCode(code, oauthVerifier)
251
+ .then((tokens) => {
252
+ _saveOAuth(chosenProvider, chosenModel);
253
+ setTimeout(() => {
254
+ onDone({ provider: chosenProvider, model: chosenModel, oauthToken: tokens.access_token });
255
+ }, 600);
256
+ })
257
+ .catch((err: Error) => {
258
+ exchangingRef.current = false;
259
+ setOauthError(err.message);
260
+ setStep("oauth-paste");
261
+ });
262
+ return;
263
+ }
264
+ if (key.backspace || key.delete) { setOauthCodeInput(k => k.slice(0, -1)); return; }
265
+ if (key.ctrl && char === "s") { setShowCode(v => !v); return; }
266
+ if (char && !key.ctrl && !key.meta) { setOauthCodeInput(k => k + char); return; }
267
+ }
268
+ }, [step, providerIdx, authIdx, modelIdx, chosenProvider, chosenAuthMethod,
269
+ chosenModel, apiKeyInput, oauthCodeInput, oauthVerifier,
270
+ getModels, onCancel, onDone]));
271
+
272
+ // ── Step label helpers ────────────────────────────────────────────────────
273
+
274
+ // Total steps: Anthropic=4, Ollama=2, others=3
275
+ const total = chosenProvider === "anthropic" ? 4 : chosenProvider === "ollama" ? 2 : 3;
276
+ // Step number depends on flow
277
+ const stepNum: Record<Step, string> = {
278
+ "provider": `1/${total}`,
279
+ "auth-method": "2/4",
280
+ "model": chosenProvider === "anthropic" ? "3/4" : chosenProvider === "ollama" ? "2/2" : "2/3",
281
+ "apikey": chosenProvider === "anthropic" ? "4/4" : "3/3",
282
+ "oauth-paste": "4/4",
283
+ "oauth-exchanging": "4/4",
284
+ "done": "",
285
+ };
286
+
287
+ // ── Renders ───────────────────────────────────────────────────────────────
288
+
289
+ if (step === "provider") {
290
+ return (
291
+ <Box flexDirection="column" paddingY={1}>
292
+ <Text color={getTheme().accent} bold>{` ⚙ Setup Wizard (${stepNum.provider} — Provider)`}</Text>
293
+ <Menu title={" Choose a provider"} items={PROVIDERS} selectedIdx={providerIdx} />
294
+ </Box>
295
+ );
296
+ }
297
+
298
+ if (step === "auth-method") {
299
+ return (
300
+ <Box flexDirection="column" paddingY={1}>
301
+ <Text color={getTheme().accent} bold>{` ⚙ Setup Wizard (${stepNum["auth-method"]} — Authentication)`}</Text>
302
+ <Menu title={" How do you want to authenticate?"} items={AUTH_METHODS} selectedIdx={authIdx} />
303
+ </Box>
304
+ );
305
+ }
306
+
307
+ if (step === "model") {
308
+ const models = getModels(chosenProvider, chosenAuthMethod);
309
+ const title = chosenProvider === "anthropic" && chosenAuthMethod === "oauth"
310
+ ? " Choose a model (OAuth supports Haiku only)"
311
+ : ` Choose a model for ${chosenProvider}`;
312
+ return (
313
+ <Box flexDirection="column" paddingY={1}>
314
+ <Text color={getTheme().accent} bold>{` ⚙ Setup Wizard (${stepNum.model} — Model)`}</Text>
315
+ <Menu title={title} items={models} selectedIdx={modelIdx} />
316
+ </Box>
317
+ );
318
+ }
319
+
320
+ if (step === "apikey") {
321
+ const tt = getTheme();
322
+ const masked = showKey ? apiKeyInput : apiKeyInput.replace(/./g, "●");
323
+ const url = KEY_URLS[chosenProvider];
324
+ return (
325
+ <Box flexDirection="column" paddingY={1} paddingLeft={2}>
326
+ <Text color={tt.accent} bold>{` ⚙ Setup Wizard (${stepNum.apikey} — API Key)`}</Text>
327
+ <Text dimColor={tt.useDim} color={tt.textDim}>{"─".repeat(50)}</Text>
328
+ <Text color={tt.text}>{` Provider: ${chosenProvider} Model: ${chosenModel}`}</Text>
329
+ {url ? <Text dimColor={tt.useDim} color={tt.textDim}>{` Get a key: ${url}`}</Text> : null}
330
+ <Text>{" "}</Text>
331
+ <Box>
332
+ <Text color={tt.prompt}>{" API key: "}</Text>
333
+ <Text color={tt.text}>{masked || " "}</Text>
334
+ <Text color={tt.cursor}>{"▊"}</Text>
335
+ </Box>
336
+ <Text>{" "}</Text>
337
+ <Text dimColor={tt.useDim} color={tt.textDim}>{" Paste key then Enter · Ctrl+S toggle visible · Enter alone to skip"}</Text>
338
+ <Text dimColor={tt.useDim} color={tt.textDim}>{" Esc cancel"}</Text>
339
+ </Box>
340
+ );
341
+ }
342
+
343
+ if (step === "oauth-paste") {
344
+ const tt = getTheme();
345
+ const maskedCode = showCode ? oauthCodeInput : oauthCodeInput.replace(/./g, "●");
346
+ return (
347
+ <Box flexDirection="column" paddingY={1} paddingLeft={2}>
348
+ <Text color={tt.accent} bold>{` ⚙ Setup Wizard (${stepNum["oauth-paste"]} — OAuth)`}</Text>
349
+ <Text dimColor={tt.useDim} color={tt.textDim}>{"─".repeat(50)}</Text>
350
+ <Text color={tt.text}>{" 1. Browser opening to Claude login…"}</Text>
351
+ {oauthUrl
352
+ ? <Text dimColor={tt.useDim} color={tt.textDim}>{` If it didn't open: ${oauthUrl.substring(0, 72)}…`}</Text>
353
+ : null}
354
+ <Text color={tt.text}>{" 2. Approve → you'll land on a page with a code in the URL"}</Text>
355
+ <Text color={tt.text}>{" 3. Copy the code= value from the URL and paste below"}</Text>
356
+ {oauthError
357
+ ? <Text color={tt.error}>{`\n ✗ ${oauthError}`}</Text>
358
+ : null}
359
+ <Text>{" "}</Text>
360
+ <Box>
361
+ <Text color={tt.prompt}>{" Code: "}</Text>
362
+ <Text color={tt.text}>{maskedCode || " "}</Text>
363
+ <Text color={tt.cursor}>{"▊"}</Text>
364
+ </Box>
365
+ <Text>{" "}</Text>
366
+ <Text dimColor={tt.useDim} color={tt.textDim}>{" Paste code then Enter · Ctrl+S toggle visible · Esc cancel"}</Text>
367
+ </Box>
368
+ );
369
+ }
370
+
371
+ if (step === "oauth-exchanging") {
372
+ const tt = getTheme();
373
+ return (
374
+ <Box flexDirection="column" paddingY={1} paddingLeft={2}>
375
+ <Text color={tt.accent} bold>{` ⚙ Setup Wizard (${stepNum["oauth-exchanging"]} — OAuth)`}</Text>
376
+ <Text dimColor={tt.useDim} color={tt.textDim}>{"─".repeat(50)}</Text>
377
+ <Text color={tt.warning}>{" Exchanging code for tokens…"}</Text>
378
+ </Box>
379
+ );
380
+ }
381
+
382
+ return null;
383
+ };
384
+
385
+ // ── Helpers ───────────────────────────────────────────────────────────────────
386
+
387
+ function _save(provider: string, model: string, apiKey: string | undefined) {
388
+ const config = loadConfig();
389
+ config.provider = provider;
390
+ config.model = model || undefined;
391
+ if (apiKey) {
392
+ config.apiKeys = config.apiKeys || {};
393
+ config.apiKeys[provider] = apiKey;
394
+ }
395
+ saveConfig(config);
396
+ }
397
+
398
+ /** OAuth: save provider/model only — token is already in keychain via exchangeOAuthCode */
399
+ function _saveOAuth(provider: string, model: string) {
400
+ const config = loadConfig();
401
+ config.provider = provider;
402
+ config.model = model || undefined;
403
+ saveConfig(config);
404
+ }
405
+
406
+ function openBrowser(url: string): void {
407
+ const { exec } = require("child_process") as typeof import("child_process");
408
+ const cmd = process.platform === "darwin" ? `open "${url}"`
409
+ : process.platform === "win32" ? `start "" "${url}"`
410
+ : `xdg-open "${url}"`;
411
+ exec(cmd, () => {});
412
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { getTheme } from "./theme";
4
+
5
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const INTERVAL_MS = 80;
7
+
8
+ // Blinking dot frames: pulsing dots for "thinking"
9
+ const DOT_FRAMES = [" ", "· ", "·· ", "···", " ··", " ·", " "];
10
+ const DOT_INTERVAL_MS = 200;
11
+
12
+ interface SpinnerProps {
13
+ label: string;
14
+ color?: string;
15
+ startTime?: number; // show elapsed time if provided
16
+ }
17
+
18
+ export const Spinner: React.FC<SpinnerProps> = ({
19
+ label,
20
+ color,
21
+ startTime,
22
+ }) => {
23
+ const t = getTheme();
24
+ const spinnerColor = color || t.spinner;
25
+ const [dotFrame, setDotFrame] = useState(0);
26
+ const [elapsed, setElapsed] = useState(0);
27
+
28
+ useEffect(() => {
29
+ const id = setInterval(() => {
30
+ setDotFrame((f) => (f + 1) % DOT_FRAMES.length);
31
+ if (startTime) {
32
+ setElapsed(Math.floor((Date.now() - startTime) / 1000));
33
+ }
34
+ }, DOT_INTERVAL_MS);
35
+ return () => clearInterval(id);
36
+ }, [startTime]);
37
+
38
+ const elapsedStr = startTime && elapsed > 0 ? ` (${elapsed}s)` : "";
39
+
40
+ return (
41
+ <Box paddingLeft={2}>
42
+ <Text color={spinnerColor as any}>{label}</Text>
43
+ <Text color={spinnerColor as any}>{DOT_FRAMES[dotFrame]}</Text>
44
+ <Text color={t.elapsed} dimColor={t.useDim}>{elapsedStr}</Text>
45
+ </Box>
46
+ );
47
+ };
48
+
49
+ interface ToolSpinnerProps {
50
+ name: string;
51
+ preview: string;
52
+ status: "running" | "done" | "error";
53
+ }
54
+
55
+ const TOOL_ICONS: Record<string, string> = {
56
+ file_read: "📖",
57
+ file_write: "✏️ ",
58
+ file_edit: "🔧",
59
+ multi_edit: "🔧",
60
+ file_delete: "🗑️",
61
+ ast_edit: "🌳",
62
+ notebook_edit: "📓",
63
+ glob_search: "🔍",
64
+ grep_search: "🔎",
65
+ codebase_search: "🔎",
66
+ shell_exec: "💻",
67
+ file_run: "▶",
68
+ web_fetch: "🌐",
69
+ web_search: "🔮",
70
+ sub_agent: "🤖",
71
+ todo: "📋",
72
+ list_dir: "📁",
73
+ view_diff: "📊",
74
+ view_repo_map: "🗺️",
75
+ code_verify: "✅",
76
+ system_info: "ℹ️",
77
+ };
78
+
79
+ export const ToolSpinner: React.FC<ToolSpinnerProps> = ({ name, preview, status }) => {
80
+ const t = getTheme();
81
+ const [frame, setFrame] = useState(0);
82
+
83
+ useEffect(() => {
84
+ if (status !== "running") return;
85
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), INTERVAL_MS);
86
+ return () => clearInterval(id);
87
+ }, [status]);
88
+
89
+ const icon = TOOL_ICONS[name] || "⚡";
90
+ const spinner = status === "running" ? FRAMES[frame] + " " : "";
91
+ const color =
92
+ status === "error" ? t.toolError : status === "done" ? t.toolDone : t.toolRunning;
93
+ const statusMark =
94
+ status === "done" ? "✓ " : status === "error" ? "✗ " : spinner;
95
+
96
+ return (
97
+ <Box paddingLeft={2}>
98
+ <Text color={color as any}>{statusMark}{icon} </Text>
99
+ <Text color={color as any}>{name}</Text>
100
+ {preview ? <Text color={t.toolPreview} dimColor={t.useDim}>{" " + preview.slice(0, 55)}</Text> : null}
101
+ </Box>
102
+ );
103
+ };