@cdoing/opentuicli 0.1.2

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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * SetupWizard — Interactive setup overlay for configuring provider, model, API key, or OAuth
3
+ *
4
+ * Steps:
5
+ * 1. Select provider
6
+ * 2. Select auth method (API key or OAuth — Anthropic only)
7
+ * 3. Select model
8
+ * 4. Enter API key / OAuth code
9
+ */
10
+
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import * as os from "os";
14
+ import { execSync } from "child_process";
15
+ import { TextAttributes } from "@opentui/core";
16
+ import { useState, useRef, useEffect } from "react";
17
+ import { useKeyboard } from "@opentui/react";
18
+ import { useTheme } from "../context/theme";
19
+
20
+ export interface SetupWizardProps {
21
+ onComplete: (config: { provider: string; model: string; apiKey?: string; oauthToken?: string }) => void;
22
+ onClose: () => void;
23
+ }
24
+
25
+ const PROVIDERS = [
26
+ { id: "anthropic", name: "Anthropic (Claude)", hint: "Claude Sonnet, Opus, Haiku" },
27
+ { id: "openai", name: "OpenAI", hint: "GPT-4o, o3" },
28
+ { id: "google", name: "Google AI", hint: "Gemini 2.0 Flash, 2.5 Pro" },
29
+ { id: "ollama", name: "Ollama (Local)", hint: "No API key needed" },
30
+ ];
31
+
32
+ const AUTH_METHODS = [
33
+ { id: "apikey", name: "API Key", hint: "all models available - console.anthropic.com" },
34
+ { id: "oauth", name: "OAuth", hint: "Claude Pro/Max - opens browser" },
35
+ ];
36
+
37
+ const MODELS: Record<string, { id: string; name: string; hint?: string }[]> = {
38
+ anthropic: [
39
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", hint: "recommended - fast & smart" },
40
+ { id: "claude-opus-4-6", name: "Claude Opus 4.6", hint: "most capable" },
41
+ { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", hint: "fastest" },
42
+ ],
43
+ openai: [
44
+ { id: "gpt-4o", name: "GPT-4o", hint: "recommended" },
45
+ { id: "gpt-4o-mini", name: "GPT-4o mini", hint: "fastest" },
46
+ { id: "o3", name: "o3", hint: "reasoning" },
47
+ ],
48
+ google: [
49
+ { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", hint: "recommended" },
50
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", hint: "most capable" },
51
+ ],
52
+ ollama: [
53
+ { id: "llama3.1", name: "Llama 3.1", hint: "general purpose" },
54
+ { id: "codellama", name: "Code Llama", hint: "code-focused" },
55
+ { id: "mistral", name: "Mistral", hint: "fast & capable" },
56
+ ],
57
+ };
58
+
59
+ const OAUTH_MODELS = [
60
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", hint: "only model supported with OAuth" },
61
+ ];
62
+
63
+ const ENV_VARS: Record<string, string> = {
64
+ anthropic: "ANTHROPIC_API_KEY",
65
+ openai: "OPENAI_API_KEY",
66
+ google: "GOOGLE_API_KEY",
67
+ };
68
+
69
+ const KEY_URLS: Record<string, string> = {
70
+ anthropic: "https://console.anthropic.com/settings/keys",
71
+ openai: "https://platform.openai.com/api-keys",
72
+ google: "https://aistudio.google.com/apikey",
73
+ };
74
+
75
+ type Step = "provider" | "auth-method" | "model" | "apikey" | "oauth-paste" | "oauth-exchanging";
76
+
77
+ function openBrowser(url: string): void {
78
+ try {
79
+ const cmd = process.platform === "darwin" ? `open "${url}"`
80
+ : process.platform === "win32" ? `start "" "${url}"`
81
+ : `xdg-open "${url}"`;
82
+ execSync(cmd, { stdio: "ignore", timeout: 3000 });
83
+ } catch { /* browser open is best-effort */ }
84
+ }
85
+
86
+ export function SetupWizard(props: SetupWizardProps) {
87
+ const { theme } = useTheme();
88
+ const t = theme;
89
+
90
+ const [step, setStep] = useState<Step>("provider");
91
+ const [selectedProvider, setSelectedProvider] = useState(0);
92
+ const [selectedAuth, setSelectedAuth] = useState(0);
93
+ const [selectedModel, setSelectedModel] = useState(0);
94
+ const [authMethod, setAuthMethod] = useState<"apikey" | "oauth">("apikey");
95
+ const [chosenProviderId, setChosenProviderId] = useState("anthropic");
96
+ const [chosenModelId, setChosenModelId] = useState("");
97
+ const [apiKeyInput, setApiKeyInput] = useState("");
98
+ const [showKey, setShowKey] = useState(false);
99
+ const [error, setError] = useState("");
100
+
101
+ // OAuth state
102
+ const [oauthUrl, setOauthUrl] = useState("");
103
+ const [oauthVerifier, setOauthVerifier] = useState("");
104
+ const [oauthCodeInput, setOauthCodeInput] = useState("");
105
+ const [showCode, setShowCode] = useState(false);
106
+ const [oauthError, setOauthError] = useState("");
107
+ const exchangingRef = useRef(false);
108
+
109
+ const provider = PROVIDERS[selectedProvider];
110
+ const models = authMethod === "oauth" ? OAUTH_MODELS : (MODELS[chosenProviderId] || []);
111
+
112
+ // Generate OAuth URL when entering oauth-paste step
113
+ useEffect(() => {
114
+ if (step !== "oauth-paste") return;
115
+ try {
116
+ const { generateOAuthUrl } = require("@cdoing/core");
117
+ const { url, codeVerifier } = generateOAuthUrl(chosenProviderId);
118
+ setOauthUrl(url);
119
+ setOauthVerifier(codeVerifier);
120
+ setOauthCodeInput("");
121
+ setOauthError("");
122
+ openBrowser(url);
123
+ } catch {
124
+ setOauthError("OAuth not available. Install @cdoing/core with OAuth support.");
125
+ }
126
+ }, [step, chosenProviderId]);
127
+
128
+ useKeyboard((key: any) => {
129
+ // Escape — go back or close
130
+ if (key.name === "escape") {
131
+ if (step === "oauth-paste" || step === "oauth-exchanging") {
132
+ setStep("model");
133
+ setOauthError("");
134
+ } else if (step === "apikey") {
135
+ setStep("model");
136
+ setError("");
137
+ } else if (step === "model") {
138
+ setStep(chosenProviderId === "anthropic" ? "auth-method" : "provider");
139
+ } else if (step === "auth-method") {
140
+ setStep("provider");
141
+ } else {
142
+ props.onClose();
143
+ }
144
+ return;
145
+ }
146
+
147
+ // ── Step 1: Provider ──
148
+ if (step === "provider") {
149
+ if (key.name === "up" || key.name === "k") {
150
+ setSelectedProvider((s) => Math.max(0, s - 1));
151
+ } else if (key.name === "down" || key.name === "j") {
152
+ setSelectedProvider((s) => Math.min(PROVIDERS.length - 1, s + 1));
153
+ } else if (key.name === "return") {
154
+ const p = PROVIDERS[selectedProvider];
155
+ setChosenProviderId(p.id);
156
+ setSelectedModel(0);
157
+ if (p.id === "anthropic") {
158
+ setSelectedAuth(0);
159
+ setStep("auth-method");
160
+ } else {
161
+ setAuthMethod("apikey");
162
+ setStep("model");
163
+ }
164
+ }
165
+ return;
166
+ }
167
+
168
+ // ── Step 2: Auth method (Anthropic only) ──
169
+ if (step === "auth-method") {
170
+ if (key.name === "up" || key.name === "k") {
171
+ setSelectedAuth((s) => Math.max(0, s - 1));
172
+ } else if (key.name === "down" || key.name === "j") {
173
+ setSelectedAuth((s) => Math.min(AUTH_METHODS.length - 1, s + 1));
174
+ } else if (key.name === "return") {
175
+ const auth = AUTH_METHODS[selectedAuth].id as "apikey" | "oauth";
176
+ setAuthMethod(auth);
177
+ setSelectedModel(0);
178
+ setStep("model");
179
+ }
180
+ return;
181
+ }
182
+
183
+ // ── Step 3: Model ──
184
+ if (step === "model") {
185
+ if (key.name === "up" || key.name === "k") {
186
+ setSelectedModel((s) => Math.max(0, s - 1));
187
+ } else if (key.name === "down" || key.name === "j") {
188
+ setSelectedModel((s) => Math.min(models.length - 1, s + 1));
189
+ } else if (key.name === "return") {
190
+ const m = models[selectedModel];
191
+ setChosenModelId(m.id);
192
+ if (chosenProviderId === "ollama") {
193
+ saveAndComplete(chosenProviderId, m.id, undefined);
194
+ } else if (authMethod === "oauth") {
195
+ exchangingRef.current = false;
196
+ setStep("oauth-paste");
197
+ } else {
198
+ setStep("apikey");
199
+ setApiKeyInput("");
200
+ setError("");
201
+ }
202
+ }
203
+ return;
204
+ }
205
+
206
+ // ── Step 4a: API key ──
207
+ if (step === "apikey") {
208
+ if (key.name === "return") {
209
+ if (!apiKeyInput.trim()) {
210
+ setError("API key cannot be empty");
211
+ return;
212
+ }
213
+ saveAndComplete(chosenProviderId, chosenModelId, apiKeyInput.trim());
214
+ } else if (key.ctrl && key.name === "s") {
215
+ setShowKey((s) => !s);
216
+ } else if (key.name === "backspace") {
217
+ setApiKeyInput((s) => s.slice(0, -1));
218
+ setError("");
219
+ } else if (key.ctrl && key.name === "u") {
220
+ setApiKeyInput("");
221
+ setError("");
222
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
223
+ setApiKeyInput((s) => s + key.sequence);
224
+ setError("");
225
+ }
226
+ return;
227
+ }
228
+
229
+ // ── Step 4b: OAuth code paste ──
230
+ if (step === "oauth-paste") {
231
+ if (key.name === "return") {
232
+ const code = oauthCodeInput.trim();
233
+ if (!code || exchangingRef.current) return;
234
+ exchangingRef.current = true;
235
+ setStep("oauth-exchanging");
236
+ try {
237
+ const { exchangeOAuthCode } = require("@cdoing/core");
238
+ exchangeOAuthCode(code, oauthVerifier, chosenProviderId)
239
+ .then((tokens: any) => {
240
+ saveAndComplete(chosenProviderId, chosenModelId, undefined);
241
+ props.onComplete({
242
+ provider: chosenProviderId,
243
+ model: chosenModelId,
244
+ oauthToken: tokens.access_token,
245
+ });
246
+ })
247
+ .catch((err: Error) => {
248
+ exchangingRef.current = false;
249
+ setOauthError(err.message);
250
+ setStep("oauth-paste");
251
+ });
252
+ } catch {
253
+ exchangingRef.current = false;
254
+ setOauthError("OAuth exchange failed");
255
+ setStep("oauth-paste");
256
+ }
257
+ return;
258
+ }
259
+ if (key.ctrl && key.name === "s") {
260
+ setShowCode((s) => !s);
261
+ } else if (key.name === "backspace") {
262
+ setOauthCodeInput((s) => s.slice(0, -1));
263
+ setOauthError("");
264
+ } else if (key.ctrl && key.name === "u") {
265
+ setOauthCodeInput("");
266
+ setOauthError("");
267
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
268
+ setOauthCodeInput((s) => s + key.sequence);
269
+ setOauthError("");
270
+ }
271
+ return;
272
+ }
273
+ });
274
+
275
+ const saveAndComplete = (prov: string, model: string, apiKey?: string) => {
276
+ const configDir = path.join(os.homedir(), ".cdoing");
277
+ const configPath = path.join(configDir, "config.json");
278
+
279
+ let config: Record<string, any> = {};
280
+ try {
281
+ if (fs.existsSync(configPath)) {
282
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
283
+ }
284
+ } catch {}
285
+
286
+ config.provider = prov;
287
+ config.model = model;
288
+ if (apiKey) {
289
+ if (!config.apiKeys) config.apiKeys = {};
290
+ config.apiKeys[prov] = apiKey;
291
+ }
292
+
293
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
294
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
295
+
296
+ props.onComplete({ provider: prov, model, apiKey });
297
+ };
298
+
299
+ // Step numbers for display
300
+ const isAnthropic = chosenProviderId === "anthropic";
301
+ const total = isAnthropic ? 4 : chosenProviderId === "ollama" ? 2 : 3;
302
+ const stepLabel = (s: Step): string => {
303
+ switch (s) {
304
+ case "provider": return `1/${total}`;
305
+ case "auth-method": return "2/4";
306
+ case "model": return isAnthropic ? "3/4" : chosenProviderId === "ollama" ? "2/2" : "2/3";
307
+ case "apikey": return isAnthropic ? "4/4" : "3/3";
308
+ case "oauth-paste": return "4/4";
309
+ case "oauth-exchanging": return "4/4";
310
+ default: return "";
311
+ }
312
+ };
313
+
314
+ return (
315
+ <box
316
+ borderStyle="single"
317
+ borderColor={t.primary}
318
+ paddingX={2}
319
+ paddingY={1}
320
+ flexDirection="column"
321
+ flexGrow={1}
322
+ >
323
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>
324
+ {"Setup Wizard"}
325
+ </text>
326
+ <text>{""}</text>
327
+
328
+ {/* Step 1: Provider */}
329
+ {step === "provider" && (
330
+ <box flexDirection="column">
331
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
332
+ {`Step ${stepLabel("provider")}: Select Provider`}
333
+ </text>
334
+ <text>{""}</text>
335
+ {PROVIDERS.map((p, i) => (
336
+ <text
337
+ key={p.id}
338
+ fg={selectedProvider === i ? t.primary : t.textMuted}
339
+ attributes={selectedProvider === i ? TextAttributes.BOLD : undefined}
340
+ >
341
+ {` ${selectedProvider === i ? ">" : " "} ${p.name} ${selectedProvider === i ? p.hint : ""}`}
342
+ </text>
343
+ ))}
344
+ <text>{""}</text>
345
+ <text fg={t.textMuted}>{" Up/Down Navigate Enter Select Esc Cancel"}</text>
346
+ </box>
347
+ )}
348
+
349
+ {/* Step 2: Auth method (Anthropic only) */}
350
+ {step === "auth-method" && (
351
+ <box flexDirection="column">
352
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
353
+ {`Step ${stepLabel("auth-method")}: How do you want to authenticate?`}
354
+ </text>
355
+ <text>{""}</text>
356
+ {AUTH_METHODS.map((a, i) => (
357
+ <text
358
+ key={a.id}
359
+ fg={selectedAuth === i ? t.primary : t.textMuted}
360
+ attributes={selectedAuth === i ? TextAttributes.BOLD : undefined}
361
+ >
362
+ {` ${selectedAuth === i ? ">" : " "} ${a.name} ${selectedAuth === i ? a.hint : ""}`}
363
+ </text>
364
+ ))}
365
+ <text>{""}</text>
366
+ <text fg={t.textMuted}>{" Up/Down Navigate Enter Select Esc Back"}</text>
367
+ </box>
368
+ )}
369
+
370
+ {/* Step 3: Model */}
371
+ {step === "model" && (
372
+ <box flexDirection="column">
373
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
374
+ {authMethod === "oauth"
375
+ ? `Step ${stepLabel("model")}: Select Model (OAuth supports Haiku only)`
376
+ : `Step ${stepLabel("model")}: Select Model (${provider.name})`}
377
+ </text>
378
+ <text>{""}</text>
379
+ {models.map((m, i) => (
380
+ <text
381
+ key={m.id}
382
+ fg={selectedModel === i ? t.primary : t.textMuted}
383
+ attributes={selectedModel === i ? TextAttributes.BOLD : undefined}
384
+ >
385
+ {` ${selectedModel === i ? ">" : " "} ${m.name}${m.hint ? ` ${m.hint}` : ""}`}
386
+ </text>
387
+ ))}
388
+ <text>{""}</text>
389
+ <text fg={t.textMuted}>{" Up/Down Navigate Enter Select Esc Back"}</text>
390
+ </box>
391
+ )}
392
+
393
+ {/* Step 4a: API key */}
394
+ {step === "apikey" && (
395
+ <box flexDirection="column">
396
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
397
+ {`Step ${stepLabel("apikey")}: Enter API Key`}
398
+ </text>
399
+ <text>{""}</text>
400
+ <text fg={t.textDim}>
401
+ {` Provider: ${chosenProviderId} Model: ${chosenModelId}`}
402
+ </text>
403
+ {KEY_URLS[chosenProviderId] && (
404
+ <text fg={t.textDim}>
405
+ {` Get a key: ${KEY_URLS[chosenProviderId]}`}
406
+ </text>
407
+ )}
408
+ <text fg={t.textDim}>
409
+ {` Environment variable: ${ENV_VARS[chosenProviderId] || "N/A"}`}
410
+ </text>
411
+ <text>{""}</text>
412
+ <text fg={t.text}>
413
+ {` > ${showKey ? apiKeyInput : apiKeyInput.replace(/./g, "*")}|`}
414
+ </text>
415
+ {error && (
416
+ <text fg={t.error}>{`\n ${error}`}</text>
417
+ )}
418
+ <text>{""}</text>
419
+ <text fg={t.textMuted}>{" Enter Save Ctrl+S Toggle visibility Ctrl+U Clear Esc Back"}</text>
420
+ </box>
421
+ )}
422
+
423
+ {/* Step 4b: OAuth paste */}
424
+ {step === "oauth-paste" && (
425
+ <box flexDirection="column">
426
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
427
+ {`Step ${stepLabel("oauth-paste")}: OAuth Login`}
428
+ </text>
429
+ <text>{""}</text>
430
+ <text fg={t.text}>{" 1. Browser opening to Claude login..."}</text>
431
+ {oauthUrl ? (
432
+ <text fg={t.textDim}>{` If it didn't open: ${oauthUrl.substring(0, 70)}...`}</text>
433
+ ) : null}
434
+ <text fg={t.text}>{" 2. Approve -> you'll land on a page with a code in the URL"}</text>
435
+ <text fg={t.text}>{" 3. Copy the code= value from the URL and paste below"}</text>
436
+ {oauthError ? (
437
+ <>
438
+ <text>{""}</text>
439
+ <text fg={t.error}>{` Error: ${oauthError}`}</text>
440
+ </>
441
+ ) : null}
442
+ <text>{""}</text>
443
+ <text fg={t.text}>
444
+ {` Code: ${showCode ? oauthCodeInput : oauthCodeInput.replace(/./g, "*")}|`}
445
+ </text>
446
+ <text>{""}</text>
447
+ <text fg={t.textMuted}>{" Paste code then Enter Ctrl+S Toggle visible Esc Cancel"}</text>
448
+ </box>
449
+ )}
450
+
451
+ {/* Step 4b: OAuth exchanging */}
452
+ {step === "oauth-exchanging" && (
453
+ <box flexDirection="column">
454
+ <text fg={t.text} attributes={TextAttributes.BOLD}>
455
+ {`Step ${stepLabel("oauth-exchanging")}: OAuth Login`}
456
+ </text>
457
+ <text>{""}</text>
458
+ <text fg={t.warning}>{" Exchanging code for tokens..."}</text>
459
+ </box>
460
+ )}
461
+ </box>
462
+ );
463
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Sidebar — collapsible right panel with sections.
3
+ * Uses single text per line to avoid layout concatenation issues.
4
+ */
5
+
6
+ import { TextAttributes } from "@opentui/core";
7
+ import { useTheme } from "../context/theme";
8
+
9
+ const W = 34; // content width (inside the border)
10
+
11
+ export interface SidebarProps {
12
+ provider: string;
13
+ model: string;
14
+ workingDir: string;
15
+ tokens?: { input: number; output: number };
16
+ contextPercent?: number;
17
+ activeTool?: string;
18
+ status: string;
19
+ sessionTitle?: string;
20
+ themeId?: string;
21
+ modifiedFiles?: Array<{ path: string; additions: number; deletions: number }>;
22
+ }
23
+
24
+ function trunc(str: string, max: number): string {
25
+ return str.length > max ? str.slice(0, max - 1) + "…" : str;
26
+ }
27
+
28
+ function bar(pct: number, len: number): string {
29
+ const filled = Math.round((pct / 100) * len);
30
+ return "█".repeat(filled) + "░".repeat(len - filled);
31
+ }
32
+
33
+ export function Sidebar(props: SidebarProps) {
34
+ const { theme } = useTheme();
35
+ const t = theme;
36
+
37
+ const home = process.env.HOME || "";
38
+ const dir = home && props.workingDir.startsWith(home)
39
+ ? "~" + props.workingDir.slice(home.length) : props.workingDir;
40
+
41
+ const pct = props.contextPercent ? Math.round(props.contextPercent) : 0;
42
+ const pctColor = pct > 75 ? t.error : pct > 50 ? t.warning : t.success;
43
+ const inTok = props.tokens ? props.tokens.input.toLocaleString() : "0";
44
+ const outTok = props.tokens ? props.tokens.output.toLocaleString() : "0";
45
+ const statusColor = props.status === "Error" ? t.error
46
+ : props.status === "Processing..." ? t.warning : t.success;
47
+
48
+ // Each line is a single string rendered in one <text> to avoid concatenation issues
49
+ const lines: Array<{ text: string; fg: any; bold?: boolean }> = [];
50
+
51
+ const sep = () => lines.push({ text: "│", fg: t.border });
52
+ const header = (title: string) => {
53
+ lines.push({ text: `│ ${title}`, fg: t.primary, bold: true });
54
+ };
55
+ const row = (label: string, value: string, fg?: any) => {
56
+ const padded = label ? `│ ${label.padEnd(10)} ${trunc(value, W - 14)}` : `│ ${trunc(value, W - 4)}`;
57
+ lines.push({ text: padded, fg: fg || t.text });
58
+ };
59
+ const shortcut = (key: string, label: string) => {
60
+ lines.push({ text: `│ ${key.padEnd(10)} ${label}`, fg: t.textDim });
61
+ };
62
+
63
+ // ── Session ──
64
+ header("Session");
65
+ row("", props.sessionTitle || "New Session");
66
+ row("Dir", trunc(dir, W - 14));
67
+ row("Provider", props.provider);
68
+ row("Model", props.model);
69
+ if (props.themeId) row("Theme", props.themeId);
70
+ sep();
71
+
72
+ // ── Context ──
73
+ header("Context");
74
+ row("Input", `${inTok} tokens`);
75
+ row("Output", `${outTok} tokens`);
76
+ lines.push({ text: `│ ${bar(pct, 16)} ${pct}%`, fg: pctColor });
77
+ sep();
78
+
79
+ // ── Activity ──
80
+ header("Activity");
81
+ lines.push({ text: `│ Status ${props.status}`, fg: statusColor });
82
+ if (props.activeTool) {
83
+ lines.push({ text: `│ Tool ${trunc(props.activeTool, W - 14)}`, fg: t.toolRunning });
84
+ }
85
+ sep();
86
+
87
+ // ── Modified Files ──
88
+ if (props.modifiedFiles && props.modifiedFiles.length > 0) {
89
+ header(`Files (${props.modifiedFiles.length})`);
90
+ for (const f of props.modifiedFiles.slice(0, 6)) {
91
+ const name = f.path.split("/").pop() || f.path;
92
+ const diff = (f.additions > 0 ? ` +${f.additions}` : "") + (f.deletions > 0 ? ` -${f.deletions}` : "");
93
+ lines.push({ text: `│ ${trunc(name, W - 12)}${diff}`, fg: t.text });
94
+ }
95
+ if (props.modifiedFiles.length > 6) {
96
+ lines.push({ text: `│ … ${props.modifiedFiles.length - 6} more`, fg: t.textDim });
97
+ }
98
+ sep();
99
+ }
100
+
101
+ // ── Shortcuts ──
102
+ header("Shortcuts");
103
+ shortcut("Ctrl+B", "Sidebar");
104
+ shortcut("Ctrl+N", "New session");
105
+ shortcut("Ctrl+P", "Model");
106
+ shortcut("Ctrl+T", "Theme");
107
+ shortcut("Ctrl+S", "Sessions");
108
+ shortcut("Ctrl+X", "Commands");
109
+ shortcut("F1", "Help");
110
+
111
+ return (
112
+ <box width={W + 2} flexDirection="column">
113
+ <box flexDirection="column" flexGrow={1}>
114
+ {lines.map((line, i) => (
115
+ <text
116
+ key={i}
117
+ fg={line.fg}
118
+ attributes={line.bold ? TextAttributes.BOLD : undefined}
119
+ >
120
+ {line.text}
121
+ </text>
122
+ ))}
123
+ {/* Fill remaining space with border */}
124
+ <box flexGrow={1}>
125
+ <text fg={t.border}>{"│"}</text>
126
+ </box>
127
+ </box>
128
+ </box>
129
+ );
130
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * StatusBar — bottom bar with model info, tokens, context %, keybinds
3
+ */
4
+
5
+ import { TextAttributes } from "@opentui/core";
6
+ import { useTheme } from "../context/theme";
7
+
8
+ export interface StatusBarProps {
9
+ provider: string;
10
+ model: string;
11
+ mode: string;
12
+ workingDir: string;
13
+ tokens?: { input: number; output: number };
14
+ contextPercent?: number;
15
+ activeTool?: string;
16
+ isProcessing?: boolean;
17
+ }
18
+
19
+ export function StatusBar(props: StatusBarProps) {
20
+ const { theme } = useTheme();
21
+ const t = theme;
22
+
23
+ const home = process.env.HOME || "";
24
+ const shortDir = home && props.workingDir.startsWith(home)
25
+ ? "~" + props.workingDir.slice(home.length)
26
+ : props.workingDir;
27
+
28
+ const tokenInfo = props.tokens
29
+ ? ` ${props.tokens.input.toLocaleString()}→${props.tokens.output.toLocaleString()}`
30
+ : "";
31
+
32
+ const pct = props.contextPercent ? Math.round(props.contextPercent) : 0;
33
+ const contextBar = pct > 0 ? ` ctx:${pct}%` : "";
34
+
35
+ return (
36
+ <box height={1} flexDirection="row" justifyContent="space-between">
37
+ <box flexDirection="row">
38
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>
39
+ {` ${props.provider}`}
40
+ </text>
41
+ <text fg={t.textMuted}>{`/${props.model}`}</text>
42
+ <text fg={t.textDim}>{" │ "}</text>
43
+ <text fg={t.warning}>{props.mode}</text>
44
+ {tokenInfo && (
45
+ <>
46
+ <text fg={t.textDim}>{" │"}</text>
47
+ <text fg={t.textMuted}>{tokenInfo}</text>
48
+ </>
49
+ )}
50
+ {contextBar && (
51
+ <>
52
+ <text fg={t.textDim}>{" │"}</text>
53
+ <text fg={pct > 75 ? t.warning : t.textMuted}>{contextBar}</text>
54
+ </>
55
+ )}
56
+ {props.activeTool && (
57
+ <>
58
+ <text fg={t.textDim}>{" │ "}</text>
59
+ <text fg={t.toolRunning}>{`⏳ ${props.activeTool}`}</text>
60
+ </>
61
+ )}
62
+ {props.isProcessing && !props.activeTool && (
63
+ <>
64
+ <text fg={t.textDim}>{" │ "}</text>
65
+ <text fg={t.primary}>{"thinking..."}</text>
66
+ </>
67
+ )}
68
+ </box>
69
+ <box flexDirection="row">
70
+ <text fg={t.textDim}>{shortDir}</text>
71
+ <text fg={t.textDim}>{" │ "}</text>
72
+ <text fg={t.textMuted}>{"^N:New ^P:Model ^C:Quit"}</text>
73
+ </box>
74
+ </box>
75
+ );
76
+ }