@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/README.md +22 -13
- package/dist/cdoing-tui-darwin-arm64/bin/cdoing-tui +0 -0
- package/package.json +12 -9
- package/dist/index.js +0 -64
- package/dist/index.js.map +0 -7
- package/esbuild.config.cjs +0 -45
- package/src/app.tsx +0 -787
- package/src/components/dialog-command.tsx +0 -207
- package/src/components/dialog-help.tsx +0 -151
- package/src/components/dialog-model.tsx +0 -142
- package/src/components/dialog-status.tsx +0 -84
- package/src/components/dialog-theme.tsx +0 -318
- package/src/components/input-area.tsx +0 -380
- package/src/components/loading-spinner.tsx +0 -28
- package/src/components/message-list.tsx +0 -546
- package/src/components/permission-prompt.tsx +0 -72
- package/src/components/session-browser.tsx +0 -231
- package/src/components/session-footer.tsx +0 -30
- package/src/components/session-header.tsx +0 -39
- package/src/components/setup-wizard.tsx +0 -542
- package/src/components/sidebar.tsx +0 -183
- package/src/components/status-bar.tsx +0 -76
- package/src/components/toast.tsx +0 -139
- package/src/context/sdk.tsx +0 -40
- package/src/context/theme.tsx +0 -640
- package/src/index.ts +0 -50
- package/src/lib/autocomplete.ts +0 -262
- package/src/lib/context-providers.ts +0 -98
- package/src/lib/history.ts +0 -164
- package/src/lib/terminal-title.ts +0 -15
- package/src/routes/home.tsx +0 -148
- package/src/routes/session.tsx +0 -1309
- package/src/store/settings.ts +0 -107
- package/tsconfig.json +0 -23
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
|
-
}
|