@cdoing/opentuicli 0.1.21 → 0.1.26

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.
package/src/app.tsx DELETED
@@ -1,787 +0,0 @@
1
- /**
2
- * Main TUI Application — OpenTUI + React
3
- *
4
- * Full-featured terminal UI with:
5
- * - Agent integration (streaming, tool calls, permissions)
6
- * - Permission prompt wiring (real UI prompts, not auto-allow)
7
- * - Runtime model/provider switching with agent rebuild
8
- * - Session browser overlay (Ctrl+S)
9
- * - Setup wizard overlay (/setup)
10
- * - Keyboard-driven navigation
11
- * - Command palette (Ctrl+P)
12
- * - Theme support (dark/light/auto)
13
- * - Status bar with token counts and context %
14
- */
15
-
16
- import * as fs from "fs";
17
- import * as path from "path";
18
- import * as os from "os";
19
- import { createRoot, useKeyboard, useTerminalDimensions } from "@opentui/react";
20
- import { createCliRenderer, TextAttributes, RGBA } from "@opentui/core";
21
- import { useState, useRef, useCallback } from "react";
22
- import {
23
- ToolRegistry,
24
- PermissionManager,
25
- PermissionMode,
26
- ProcessManager,
27
- TodoStore,
28
- MemoryStore,
29
- registerAllTools,
30
- resolveOAuthToken,
31
- supportsOAuth,
32
- } from "@cdoing/core";
33
- import { AgentRunner, getDefaultModel, getApiKeyEnvVar } from "@cdoing/ai";
34
- import type { ModelConfig } from "@cdoing/ai";
35
-
36
- import { ThemeProvider, useTheme, detectTerminalTheme, restoreTerminalBackground, getThemeColors, setTerminalBackground } from "./context/theme";
37
- import { SDKProvider } from "./context/sdk";
38
- import { ToastProvider } from "./components/toast";
39
- import { useSettingsStore } from "./store/settings";
40
- import { Home } from "./routes/home";
41
- import { SessionView } from "./routes/session";
42
- import { StatusBar } from "./components/status-bar";
43
- import { SessionHeader } from "./components/session-header";
44
- import { SessionFooter } from "./components/session-footer";
45
- import { Sidebar } from "./components/sidebar";
46
- import { DialogModel } from "./components/dialog-model";
47
- import { DialogCommand } from "./components/dialog-command";
48
- import { DialogHelp } from "./components/dialog-help";
49
- import { DialogTheme } from "./components/dialog-theme";
50
- import { SessionBrowser } from "./components/session-browser";
51
- import { SetupWizard } from "./components/setup-wizard";
52
- import { DialogStatus } from "./components/dialog-status";
53
- import { setTerminalTitle, resetTerminalTitle } from "./lib/terminal-title";
54
- import type { Conversation } from "./lib/history";
55
-
56
- // ── Types ────────────────────────────────────────────────
57
-
58
- export interface TUIOptions {
59
- prompt?: string;
60
- provider: string;
61
- model?: string;
62
- apiKey?: string;
63
- baseUrl?: string;
64
- workingDir: string;
65
- mode: string;
66
- resume?: string;
67
- continue?: boolean;
68
- theme: string;
69
- }
70
-
71
- // ── App Shell ────────────────────────────────────────────
72
-
73
- type Route = "home" | "session";
74
- type Dialog = "none" | "model" | "command" | "sessions" | "setup" | "help" | "theme" | "status";
75
-
76
- function AppShell(props: {
77
- options: TUIOptions;
78
- agent: AgentRunner;
79
- registry: ToolRegistry;
80
- permissionManager: PermissionManager;
81
- }) {
82
- const dims = useTerminalDimensions();
83
- const { theme, themeId, customBg, setMode, setThemeId } = useTheme();
84
- const t = theme;
85
-
86
- const [route, setRoute] = useState<Route>(props.options.prompt ? "session" : "home");
87
- const [dialog, setDialog] = useState<Dialog>("none");
88
- const [status, setStatus] = useState("Ready");
89
- const [workingDir, setWorkingDir] = useState(props.options.workingDir);
90
- const [tokens, setTokens] = useState<{ input: number; output: number } | undefined>();
91
- const [contextPercent, setContextPercent] = useState(0);
92
- const [activeTool, setActiveTool] = useState<string | undefined>();
93
-
94
- // Persisted settings from Zustand store
95
- const provider = useSettingsStore((s) => s.provider);
96
- const model = useSettingsStore((s) => s.model);
97
- const sidebarMode = useSettingsStore((s) => s.sidebarMode);
98
- const setProvider = useSettingsStore((s) => s.setProvider);
99
- const setModel = useSettingsStore((s) => s.setModel);
100
- const setSidebarMode = useSettingsStore((s) => s.setSidebarMode);
101
-
102
- // Auto-hide sidebar when terminal is too narrow (like opencode: > 120 cols)
103
- const wide = dims.width > 120;
104
- const showSidebar = sidebarMode === "show" || (sidebarMode === "auto" && wide);
105
-
106
- const closeDialog = useCallback(() => {
107
- setDialog("none");
108
- }, []);
109
-
110
- // Mutable refs for agent rebuild
111
- const agentRef = useRef(props.agent);
112
- const registryRef = useRef(props.registry);
113
- const pmRef = useRef(props.permissionManager);
114
-
115
- // Initial message from home screen input
116
- const initialMessageRef = useRef<{ text: string; images?: import("@cdoing/ai").ImageAttachment[] } | null>(null);
117
-
118
- // ── Permission prompt bridge ────────────────────────
119
- // Store a pending permission resolve callback that the UI can call
120
- const permissionResolveRef = useRef<((decision: "allow" | "always" | "deny") => void) | null>(null);
121
- const [pendingPermission, setPendingPermission] = useState<{
122
- toolName: string;
123
- message: string;
124
- } | null>(null);
125
-
126
- // Wire PermissionManager to show UI prompt
127
- const requestPermission = useCallback((toolName: string, message: string): Promise<"allow" | "always" | "deny"> => {
128
- return new Promise((resolve) => {
129
- permissionResolveRef.current = resolve;
130
- setPendingPermission({ toolName, message });
131
- });
132
- }, []);
133
-
134
- // Set up the prompt function on the permission manager
135
- pmRef.current.setPromptFn(async (toolName: string, message: string) => {
136
- const decision = await requestPermission(toolName, message);
137
- return decision === "always" ? "allow" : decision;
138
- });
139
-
140
- // ── Agent Rebuild ──────────────────────────────────
141
- const rebuildAgent = useCallback((newProvider: string, newModel: string, apiKey?: string, oauthToken?: string) => {
142
- // Resolve API key or OAuth token
143
- // If apiKey is explicitly "" (empty string), it means logout — skip all fallbacks
144
- let resolvedKey = apiKey;
145
- let resolvedOAuthToken = oauthToken;
146
-
147
- if (!resolvedKey && !resolvedOAuthToken && apiKey !== "") {
148
- // Try OAuth first for providers that support it
149
- if (supportsOAuth(newProvider)) {
150
- resolveOAuthToken(newProvider).then((token) => {
151
- if (token) {
152
- const modelConfig: Partial<ModelConfig> = {
153
- provider: newProvider,
154
- model: newModel,
155
- oauthToken: token,
156
- baseURL: props.options.baseUrl || undefined,
157
- temperature: 0,
158
- maxTokens: 8096,
159
- };
160
- const newAgent = new AgentRunner(modelConfig, registryRef.current, pmRef.current);
161
- agentRef.current = newAgent;
162
- setProvider(newProvider);
163
- setModel(newModel);
164
- }
165
- }).catch(() => {});
166
- // If OAuth token is being resolved async, still try API key fallback synchronously
167
- }
168
-
169
- const envVar = getApiKeyEnvVar(newProvider);
170
- if (process.env[envVar]) {
171
- resolvedKey = process.env[envVar];
172
- } else {
173
- try {
174
- const configPath = path.join(os.homedir(), ".cdoing", "config.json");
175
- if (fs.existsSync(configPath)) {
176
- const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
177
- resolvedKey = config.apiKeys?.[newProvider];
178
- }
179
- } catch {}
180
- }
181
- }
182
-
183
- const modelConfig: Partial<ModelConfig> = {
184
- provider: newProvider,
185
- model: newModel,
186
- apiKey: resolvedKey || undefined,
187
- oauthToken: resolvedOAuthToken || undefined,
188
- baseURL: props.options.baseUrl || undefined,
189
- temperature: 0,
190
- maxTokens: 8096,
191
- };
192
-
193
- const newAgent = new AgentRunner(modelConfig, registryRef.current, pmRef.current);
194
- agentRef.current = newAgent;
195
- setProvider(newProvider);
196
- setModel(newModel);
197
- }, [props.options.baseUrl]);
198
-
199
- // ── Working Directory Change ────────────────────────
200
- const handleSetWorkingDir = useCallback((dir: string) => {
201
- setWorkingDir(dir);
202
- }, []);
203
-
204
- // ── Global Keyboard ──────────────────────────────────
205
-
206
- useKeyboard((key: any) => {
207
- // Don't intercept keys when a dialog is open (let the dialog handle them)
208
- // Exception: Ctrl+C and Escape should always work
209
- if (dialog !== "none") {
210
- if (key.ctrl && key.name === "c") {
211
- const cleanup = (globalThis as any).__cdoingCleanup;
212
- if (cleanup) cleanup();
213
- process.exit(0);
214
- }
215
- if (key.name === "escape") {
216
- setDialog("none");
217
- setRoute("home");
218
- }
219
- return;
220
- }
221
-
222
- // Ctrl+C — graceful quit
223
- if (key.ctrl && key.name === "c") {
224
- const cleanup = (globalThis as any).__cdoingCleanup;
225
- if (cleanup) cleanup();
226
- else process.exit(0);
227
- }
228
- // Ctrl+N — new session
229
- if (key.ctrl && key.name === "n") {
230
- setRoute("session");
231
- setStatus("Ready");
232
- }
233
- // Ctrl+P — command palette
234
- if (key.ctrl && key.name === "p") {
235
- setDialog((d) => (d === "command" ? "none" : "command"));
236
- }
237
- // Ctrl+S — session browser
238
- if (key.ctrl && key.name === "s") {
239
- setDialog((d) => (d === "sessions" ? "none" : "sessions"));
240
- }
241
- // Ctrl+B — toggle sidebar
242
- if (key.ctrl && key.name === "b") {
243
- setSidebarMode(sidebarMode === "hide" ? "show" : sidebarMode === "show" ? "hide" : showSidebar ? "hide" : "show");
244
- }
245
- // Ctrl+T — theme picker
246
- if (key.ctrl && key.name === "t") {
247
- setDialog((d) => (d === "theme" ? "none" : "theme"));
248
- }
249
- // Ctrl+O — model picker (Ctrl+M = Enter in terminals)
250
- if (key.ctrl && key.name === "o") {
251
- setDialog((d) => (d === "model" ? "none" : "model"));
252
- }
253
- // F1 — help dialog
254
- if (key.name === "f1") {
255
- setDialog((d) => (d === "help" ? "none" : "help"));
256
- }
257
- // Escape — close any dialog
258
- if (key.name === "escape") {
259
- setDialog("none");
260
- }
261
- }, {});
262
-
263
- // ── Session Resume Handler ────────────────────────
264
- const handleResumeSession = useCallback((_conv: Conversation) => {
265
- // TODO: restore conversation messages into agent history
266
- setRoute("session");
267
- setDialog("none");
268
- setStatus("Ready");
269
- }, []);
270
-
271
- return (
272
- <box width={dims.width} height={dims.height} flexDirection="column" backgroundColor={customBg ? RGBA.fromHex(customBg) : t.bg}>
273
- {/* Header bar */}
274
- <box height={1} flexDirection="row" paddingX={1} flexShrink={0} backgroundColor={t.bgSubtle}>
275
- <text fg={t.primary} attributes={TextAttributes.BOLD}>{"cdoing"}</text>
276
- <text fg={t.border}>{" │ "}</text>
277
- <text fg={t.textMuted}>{model}</text>
278
- <text fg={t.border}>{" │ "}</text>
279
- <text fg={status === "Error" ? t.error : status === "Processing..." ? t.warning : t.success}>
280
- {status}
281
- </text>
282
- </box>
283
-
284
- {/* Session header (only in session route) */}
285
- {route === "session" && (
286
- <SessionHeader
287
- title="Session"
288
- provider={provider}
289
- model={model}
290
- tokens={tokens}
291
- contextPercent={contextPercent}
292
- status={status}
293
- />
294
- )}
295
-
296
- {/* Separator */}
297
- <box height={1} flexShrink={0}>
298
- <text fg={t.border}>{"─".repeat(Math.max(dims.width, 40))}</text>
299
- </box>
300
-
301
- {/* Main content area with optional sidebar */}
302
- <box flexDirection="row" flexGrow={1}>
303
- <SDKProvider
304
- value={{
305
- agent: agentRef.current,
306
- registry: registryRef.current,
307
- permissionManager: pmRef.current,
308
- workingDir,
309
- provider,
310
- model,
311
- requestPermission,
312
- rebuildAgent,
313
- setWorkingDir: handleSetWorkingDir,
314
- }}
315
- >
316
- <box flexGrow={1} flexDirection="column">
317
- {dialog === "sessions" ? (
318
- <SessionBrowser
319
- onResume={handleResumeSession}
320
- onClose={() => setDialog("none")}
321
- />
322
- ) : dialog === "setup" ? (
323
- <SetupWizard
324
- onComplete={(config) => {
325
- rebuildAgent(config.provider, config.model, config.apiKey, config.oauthToken);
326
- setDialog("none");
327
- }}
328
- onClose={() => setDialog("none")}
329
- />
330
- ) : route === "home" ? (
331
- <Home
332
- provider={provider}
333
- model={model}
334
- workingDir={workingDir}
335
- themeId={themeId}
336
- onSubmit={(text, images) => {
337
- initialMessageRef.current = { text, images };
338
- setRoute("session");
339
- }}
340
- />
341
- ) : (
342
- <SessionView
343
- onStatus={setStatus}
344
- onTokens={(i, o) => setTokens({ input: i, output: o })}
345
- onActiveTool={setActiveTool}
346
- onContextPercent={setContextPercent}
347
- onOpenDialog={(d) => setDialog(d as Dialog)}
348
- initialMessage={initialMessageRef.current}
349
- dialogOpen={dialog !== "none"}
350
- />
351
- )}
352
- </box>
353
- </SDKProvider>
354
-
355
- {/* Vertical border between content and sidebar */}
356
- {showSidebar && (
357
- <box width={1} flexShrink={0}>
358
- <text fg={t.border}>{"│\n".repeat(Math.max(dims.height - 4, 1))}</text>
359
- </box>
360
- )}
361
-
362
- {/* Sidebar (right panel) */}
363
- {showSidebar && (
364
- <Sidebar
365
- provider={provider}
366
- model={model}
367
- workingDir={workingDir}
368
- tokens={tokens}
369
- contextPercent={contextPercent}
370
- activeTool={activeTool}
371
- status={status}
372
- themeId={themeId}
373
- />
374
- )}
375
- </box>
376
-
377
- {/* Separator */}
378
- <box height={1} flexShrink={0}>
379
- <text fg={t.border}>{"─".repeat(Math.max(dims.width, 40))}</text>
380
- </box>
381
-
382
- {/* Footer: session footer in session route, status bar always */}
383
- {route === "session" ? (
384
- <SessionFooter
385
- workingDir={workingDir}
386
- isProcessing={status === "Processing..."}
387
- />
388
- ) : (
389
- <StatusBar
390
- provider={provider}
391
- model={model}
392
- mode={props.options.mode}
393
- workingDir={workingDir}
394
- tokens={tokens}
395
- contextPercent={contextPercent}
396
- activeTool={activeTool}
397
- isProcessing={status === "Processing..."}
398
- />
399
- )}
400
-
401
- {/* Model picker dialog (overlay) */}
402
- {dialog === "model" && (
403
- <DialogModel
404
- provider={provider}
405
- currentModel={model}
406
- onSelect={(m) => {
407
- rebuildAgent(provider, m);
408
- setDialog("none");
409
- }}
410
- onClose={() => setDialog("none")}
411
- />
412
- )}
413
-
414
- {/* Command palette dialog (overlay) */}
415
- {dialog === "command" && (
416
- <DialogCommand
417
- onSelect={(commandId) => {
418
- setDialog("none");
419
- switch (commandId) {
420
- // Session
421
- case "session:new":
422
- setRoute("session");
423
- setStatus("Ready");
424
- break;
425
- case "session:browse":
426
- setDialog("sessions");
427
- break;
428
- case "session:clear":
429
- setRoute("session");
430
- setStatus("Ready");
431
- break;
432
- // Model
433
- case "model:switch":
434
- case "model:provider":
435
- setDialog("model");
436
- break;
437
- // Theme
438
- case "theme:dark":
439
- setMode("dark");
440
- break;
441
- case "theme:light":
442
- setMode("light");
443
- break;
444
- case "theme:picker":
445
- setDialog("theme");
446
- break;
447
- // Display
448
- case "display:sidebar":
449
- setSidebarMode(sidebarMode === "hide" ? "show" : sidebarMode === "show" ? "hide" : showSidebar ? "hide" : "show");
450
- break;
451
- // Tools (dispatch as slash commands into session)
452
- case "tool:shell":
453
- case "tool:search":
454
- case "tool:tree":
455
- setRoute("session");
456
- break;
457
- // System
458
- case "system:status":
459
- setDialog("status");
460
- break;
461
- case "system:help":
462
- setDialog("help");
463
- break;
464
- case "system:doctor":
465
- setStatus("Doctor");
466
- break;
467
- case "system:setup":
468
- setDialog("setup");
469
- break;
470
- case "system:exit": {
471
- const exit = (globalThis as any).__cdoingCleanup;
472
- if (exit) exit();
473
- else process.exit(0);
474
- break;
475
- }
476
- }
477
- }}
478
- onClose={() => setDialog("none")}
479
- />
480
- )}
481
-
482
- {/* Help dialog (overlay) */}
483
- {dialog === "help" && (
484
- <DialogHelp
485
- onClose={() => setDialog("none")}
486
- />
487
- )}
488
-
489
- {/* Theme picker dialog (overlay) */}
490
- {dialog === "theme" && (
491
- <DialogTheme
492
- onClose={() => setDialog("none")}
493
- />
494
- )}
495
-
496
- {/* Status dialog (overlay) */}
497
- {dialog === "status" && (
498
- <DialogStatus onClose={() => setDialog("none")} />
499
- )}
500
-
501
- </box>
502
- );
503
- }
504
-
505
- // ── Error Boundary ───────────────────────────────────────
506
-
507
- // Global error signal — set by uncaughtException/unhandledRejection handlers,
508
- // read by the AppRoot wrapper to swap in the error screen.
509
- let __fatalError: Error | null = null;
510
- let __fatalErrorSetter: ((err: Error | null) => void) | null = null;
511
-
512
- function AppRoot(props: {
513
- children: any;
514
- }) {
515
- const [error, setError] = useState<Error | null>(__fatalError);
516
- __fatalErrorSetter = setError;
517
-
518
- if (error) {
519
- return <ErrorScreen error={error} onReset={() => setError(null)} />;
520
- }
521
- return props.children;
522
- }
523
-
524
- function ErrorScreen(props: {
525
- error: Error;
526
- onReset: () => void;
527
- }) {
528
- const dims = useTerminalDimensions();
529
- const maxW = Math.max(dims.width, 40);
530
-
531
- const colors = {
532
- bg: "#0a0a0a",
533
- text: "#eeeeee",
534
- muted: "#808080",
535
- primary: "#fab283",
536
- error: "#ff6b6b",
537
- };
538
-
539
- const issueURL = `https://github.com/AhmadMuj/cdoing-agent/issues/new?title=${encodeURIComponent(`tui: fatal: ${props.error.message}`)}&body=${encodeURIComponent("```\n" + (props.error.stack || props.error.message).substring(0, 4000) + "\n```")}`;
540
-
541
- useKeyboard((key: any) => {
542
- if (key.ctrl && key.name === "c") {
543
- const cleanup = (globalThis as any).__cdoingCleanup;
544
- if (cleanup) cleanup();
545
- else process.exit(1);
546
- }
547
- if (key.name === "r") {
548
- props.onReset();
549
- }
550
- if (key.name === "q" || key.name === "escape") {
551
- const cleanup = (globalThis as any).__cdoingCleanup;
552
- if (cleanup) cleanup();
553
- else process.exit(0);
554
- }
555
- });
556
-
557
- const stackLines = (props.error.stack || "").split("\n").slice(0, Math.max(5, dims.height - 12));
558
-
559
- return (
560
- <box
561
- width={dims.width}
562
- height={dims.height}
563
- flexDirection="column"
564
- backgroundColor={colors.bg}
565
- paddingX={2}
566
- paddingY={1}
567
- >
568
- <text fg={colors.error} attributes={TextAttributes.BOLD}>
569
- {" A fatal error occurred!"}
570
- </text>
571
- <text>{""}</text>
572
- <text fg={colors.text} attributes={TextAttributes.BOLD}>
573
- {` ${props.error.message}`}
574
- </text>
575
- <text>{""}</text>
576
- <text fg={colors.muted}>{" Stack trace:"}</text>
577
- {stackLines.map((line, i) => (
578
- <text key={i} fg={colors.muted}>
579
- {` ${line}`}
580
- </text>
581
- ))}
582
- <text>{""}</text>
583
- <text fg={colors.primary}>{" Report this issue:"}</text>
584
- <text fg={colors.muted}>{` ${issueURL.length > maxW - 4 ? issueURL.substring(0, maxW - 7) + "..." : issueURL}`}</text>
585
- <text>{""}</text>
586
- <box height={1} flexShrink={0}>
587
- <text fg={colors.muted}>{"─".repeat(maxW)}</text>
588
- </box>
589
- <text fg={colors.text}>{" r Reset TUI • q/Esc Exit • Ctrl+C Force quit"}</text>
590
- </box>
591
- );
592
- }
593
-
594
- // ── Entry Point ──────────────────────────────────────────
595
-
596
- export async function startTUI(options: TUIOptions): Promise<void> {
597
- // Initialize core services
598
- const registry = new ToolRegistry();
599
- const permMode = options.mode === "auto" ? PermissionMode.BYPASS
600
- : options.mode === "auto-edit" ? PermissionMode.ACCEPT_EDITS
601
- : PermissionMode.DEFAULT;
602
- const pm = new PermissionManager(permMode, options.workingDir);
603
-
604
- // Permission prompt will be wired up via React state in AppShell
605
- // Set a temporary default — will be overridden once React mounts
606
- pm.setPromptFn(async (_toolName, _message) => {
607
- return "allow";
608
- });
609
-
610
- const processManager = new ProcessManager();
611
- const todoStore = new TodoStore();
612
- const memoryStore = new MemoryStore(options.workingDir);
613
- await registerAllTools(registry, {
614
- workingDir: options.workingDir,
615
- permissionManager: pm,
616
- processManager,
617
- todoStore,
618
- memoryStore,
619
- planExitCallback: (summary: string) => {
620
- // Signal that plan is ready — the session component handles the approval via /plan approve
621
- console.log("\n 📋 Plan ready: " + summary);
622
- console.log(" Use /plan approve, /plan reject, or /plan show\n");
623
- },
624
- });
625
-
626
- // Resolve API key: flag → env var → stored config → OAuth token
627
- let resolvedApiKey = options.apiKey;
628
- let resolvedOAuthToken: string | undefined;
629
- let resolvedProvider = options.provider;
630
- let resolvedModel = options.model;
631
- let resolvedBaseUrl = options.baseUrl;
632
-
633
- if (!resolvedApiKey) {
634
- // Load stored config
635
- const configPath = path.join(os.homedir(), ".cdoing", "config.json");
636
- let storedConfig: Record<string, any> = {};
637
- try {
638
- if (fs.existsSync(configPath)) {
639
- storedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
640
- }
641
- } catch {}
642
-
643
- // Apply stored provider/model/baseUrl if not set via flags
644
- if (resolvedProvider === "anthropic" && storedConfig.provider) {
645
- resolvedProvider = storedConfig.provider;
646
- }
647
- if (!resolvedModel && storedConfig.model) {
648
- resolvedModel = storedConfig.model;
649
- }
650
- if (!resolvedBaseUrl && storedConfig.baseUrl) {
651
- resolvedBaseUrl = storedConfig.baseUrl;
652
- }
653
-
654
- // Check env var
655
- const envVar = getApiKeyEnvVar(resolvedProvider);
656
- if (process.env[envVar]) {
657
- resolvedApiKey = process.env[envVar];
658
- }
659
- // Check stored API keys
660
- else if (storedConfig.apiKeys?.[resolvedProvider]) {
661
- resolvedApiKey = storedConfig.apiKeys[resolvedProvider];
662
- }
663
-
664
- // If no API key found, try OAuth token
665
- if (!resolvedApiKey && supportsOAuth(resolvedProvider)) {
666
- try {
667
- const token = await resolveOAuthToken(resolvedProvider);
668
- if (token) resolvedOAuthToken = token;
669
- } catch {}
670
- }
671
- }
672
-
673
- // Hydrate settings store with resolved CLI values (overrides persisted defaults when flags are explicit)
674
- const settingsStore = useSettingsStore.getState();
675
- if (resolvedProvider && resolvedProvider !== "anthropic") {
676
- settingsStore.setProvider(resolvedProvider);
677
- } else if (!settingsStore.provider || settingsStore.provider === "anthropic") {
678
- settingsStore.setProvider(resolvedProvider);
679
- }
680
- if (resolvedModel) {
681
- settingsStore.setModel(resolvedModel);
682
- } else if (!settingsStore.model) {
683
- settingsStore.setModel(getDefaultModel(settingsStore.provider) || "default");
684
- }
685
-
686
- // Use persisted values as the effective config (store is now hydrated)
687
- const effectiveProvider = useSettingsStore.getState().provider;
688
- const effectiveModel = useSettingsStore.getState().model;
689
-
690
- // Build model config (use effective values from persisted store)
691
- const modelConfig: Partial<ModelConfig> = {
692
- provider: effectiveProvider,
693
- model: effectiveModel || undefined,
694
- apiKey: resolvedApiKey || undefined,
695
- oauthToken: resolvedOAuthToken || undefined,
696
- baseURL: resolvedBaseUrl || undefined,
697
- temperature: 0,
698
- maxTokens: 8096,
699
- };
700
-
701
- // Create agent
702
- const agent = new AgentRunner(modelConfig, registry, pm);
703
-
704
- // Detect terminal background color before rendering (async OSC 11 query)
705
- let detectedMode: "dark" | "light" | undefined;
706
- if (options.theme === "auto") {
707
- detectedMode = await detectTerminalTheme();
708
- }
709
-
710
- // Set terminal background BEFORE clearing so it fills the entire screen
711
- const resolvedMode: "dark" | "light" = options.theme === "light" ? "light"
712
- : options.theme === "auto" ? (detectedMode || "dark")
713
- : "dark";
714
- // Hydrate theme settings from store
715
- const persistedThemeId = useSettingsStore.getState().themeId;
716
- const persistedMode = useSettingsStore.getState().mode;
717
- if (options.theme !== "light" && options.theme !== "dark") {
718
- // "auto" mode — use persisted mode if available
719
- if (persistedMode) settingsStore.setMode(persistedMode);
720
- } else {
721
- settingsStore.setMode(options.theme === "light" ? "light" : "dark");
722
- }
723
-
724
- const initialColors = getThemeColors(persistedThemeId || "default", resolvedMode);
725
- setTerminalBackground(initialColors.bg);
726
-
727
- // Reset terminal size to a good default (80x24 minimum)
728
- const cols = Math.max(process.stdout.columns || 80, 80);
729
- const rows = Math.max(process.stdout.rows || 24, 24);
730
- process.stdout.write(`\x1b[8;${rows};${cols}t`);
731
-
732
- console.clear();
733
-
734
- // Set terminal title on mount
735
- setTerminalTitle("cdoing");
736
-
737
- const renderer = await createCliRenderer({
738
- useMouse: true,
739
- exitOnCtrlC: false,
740
- });
741
- const root = createRoot(renderer);
742
- // Install global error handlers to catch uncaught exceptions and show the error screen
743
- const handleFatalError = (err: unknown) => {
744
- const error = err instanceof Error ? err : new Error(String(err));
745
- process.stderr.write(`\n[cdoing] Fatal error: ${error.message}\n${error.stack || ""}\n`);
746
- __fatalError = error;
747
- if (__fatalErrorSetter) __fatalErrorSetter(error);
748
- };
749
- process.on("uncaughtException", handleFatalError);
750
- process.on("unhandledRejection", handleFatalError);
751
-
752
- root.render(
753
- <AppRoot>
754
- <ThemeProvider mode={options.theme} themeId={persistedThemeId} detectedMode={detectedMode} syncTerminalBg>
755
- <ToastProvider>
756
- <AppShell
757
- options={{ ...options, provider: effectiveProvider, model: effectiveModel || undefined }}
758
- agent={agent}
759
- registry={registry}
760
- permissionManager={pm}
761
- />
762
- </ToastProvider>
763
- </ThemeProvider>
764
- </AppRoot>
765
- );
766
-
767
- // Graceful cleanup: unmount React, destroy renderer, restore terminal
768
- let isCleaningUp = false;
769
- const cleanup = () => {
770
- if (isCleaningUp) return;
771
- isCleaningUp = true;
772
- try { root.unmount(); } catch {}
773
- try { renderer.destroy(); } catch {}
774
- resetTerminalTitle();
775
- restoreTerminalBackground();
776
- process.exit(0);
777
- };
778
-
779
- process.on("SIGINT", cleanup);
780
- process.on("SIGTERM", cleanup);
781
-
782
- // Expose cleanup globally so the keyboard handler can use it
783
- (globalThis as any).__cdoingCleanup = cleanup;
784
-
785
- // Keep alive
786
- await new Promise(() => {});
787
- }