@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.
- package/dist/index.js +48 -0
- package/dist/index.js.map +7 -0
- package/esbuild.config.cjs +44 -0
- package/package.json +34 -0
- package/src/app.tsx +566 -0
- package/src/components/dialog-command.tsx +204 -0
- package/src/components/dialog-help.tsx +227 -0
- package/src/components/dialog-model.tsx +93 -0
- package/src/components/dialog-status.tsx +122 -0
- package/src/components/dialog-theme.tsx +292 -0
- package/src/components/input-area.tsx +318 -0
- package/src/components/loading-spinner.tsx +28 -0
- package/src/components/message-list.tsx +338 -0
- package/src/components/permission-prompt.tsx +71 -0
- package/src/components/session-browser.tsx +220 -0
- package/src/components/session-footer.tsx +30 -0
- package/src/components/session-header.tsx +39 -0
- package/src/components/setup-wizard.tsx +463 -0
- package/src/components/sidebar.tsx +130 -0
- package/src/components/status-bar.tsx +76 -0
- package/src/components/toast.tsx +139 -0
- package/src/context/sdk.tsx +40 -0
- package/src/context/theme.tsx +532 -0
- package/src/index.ts +50 -0
- package/src/lib/autocomplete.ts +258 -0
- package/src/lib/context-providers.ts +98 -0
- package/src/lib/history.ts +164 -0
- package/src/lib/terminal-title.ts +15 -0
- package/src/routes/home.tsx +148 -0
- package/src/routes/session.tsx +1186 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const esbuild = require("esbuild");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const isWatch = process.argv.includes("--watch");
|
|
5
|
+
|
|
6
|
+
const build = {
|
|
7
|
+
entryPoints: [path.resolve(__dirname, "src/index.ts")],
|
|
8
|
+
bundle: true,
|
|
9
|
+
outfile: path.resolve(__dirname, "dist/index.js"),
|
|
10
|
+
format: "esm",
|
|
11
|
+
platform: "node",
|
|
12
|
+
target: "esnext",
|
|
13
|
+
sourcemap: true,
|
|
14
|
+
minify: !isWatch,
|
|
15
|
+
jsx: "automatic",
|
|
16
|
+
jsxImportSource: "@opentui/react",
|
|
17
|
+
// @opentui packages are Bun-only — keep them external, resolved at runtime by Bun
|
|
18
|
+
external: [
|
|
19
|
+
"@cdoing/core",
|
|
20
|
+
"@cdoing/ai",
|
|
21
|
+
"@opentui/core",
|
|
22
|
+
"@opentui/react",
|
|
23
|
+
"react",
|
|
24
|
+
"react/jsx-runtime",
|
|
25
|
+
"commander",
|
|
26
|
+
"chalk",
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
if (isWatch) {
|
|
32
|
+
const ctx = await esbuild.context(build);
|
|
33
|
+
await ctx.watch();
|
|
34
|
+
console.log("Watching for changes...");
|
|
35
|
+
} else {
|
|
36
|
+
await esbuild.build(build);
|
|
37
|
+
console.log("Build complete.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main().catch((err) => {
|
|
42
|
+
console.error(err);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cdoing/opentuicli",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenTUI-based terminal interface for cdoing agent (inspired by opencode's TUI)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"cdoing-tui": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "node esbuild.config.cjs",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"dev": "node esbuild.config.cjs --watch",
|
|
15
|
+
"start": "bun dist/index.js",
|
|
16
|
+
"clean": "rm -rf dist"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@cdoing/ai": "*",
|
|
20
|
+
"@cdoing/core": "*",
|
|
21
|
+
"@opentui/core": "0.1.87",
|
|
22
|
+
"@opentui/react": "0.1.87",
|
|
23
|
+
"chalk": "^4.1.2",
|
|
24
|
+
"commander": "^13.1.0",
|
|
25
|
+
"react": "^19.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.13.10",
|
|
29
|
+
"@types/react": "^19.1.0",
|
|
30
|
+
"esbuild": "^0.25.0",
|
|
31
|
+
"ts-node": "^10.9.2",
|
|
32
|
+
"typescript": "^5.8.2"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
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
|
+
* - Model picker dialog (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 } from "@opentui/core";
|
|
21
|
+
import { useState, useRef, useCallback } from "react";
|
|
22
|
+
import {
|
|
23
|
+
ToolRegistry,
|
|
24
|
+
PermissionManager,
|
|
25
|
+
PermissionMode,
|
|
26
|
+
registerAllTools,
|
|
27
|
+
} from "@cdoing/core";
|
|
28
|
+
import { AgentRunner, getDefaultModel, getApiKeyEnvVar } from "@cdoing/ai";
|
|
29
|
+
import type { ModelConfig } from "@cdoing/ai";
|
|
30
|
+
|
|
31
|
+
import { ThemeProvider, useTheme, detectTerminalTheme, restoreTerminalBackground, getThemeColors, setTerminalBackground } from "./context/theme";
|
|
32
|
+
import { SDKProvider } from "./context/sdk";
|
|
33
|
+
import { ToastProvider } from "./components/toast";
|
|
34
|
+
import { Home } from "./routes/home";
|
|
35
|
+
import { SessionView } from "./routes/session";
|
|
36
|
+
import { StatusBar } from "./components/status-bar";
|
|
37
|
+
import { SessionHeader } from "./components/session-header";
|
|
38
|
+
import { SessionFooter } from "./components/session-footer";
|
|
39
|
+
import { Sidebar } from "./components/sidebar";
|
|
40
|
+
import { DialogModel } from "./components/dialog-model";
|
|
41
|
+
import { DialogCommand } from "./components/dialog-command";
|
|
42
|
+
import { DialogHelp } from "./components/dialog-help";
|
|
43
|
+
import { DialogTheme } from "./components/dialog-theme";
|
|
44
|
+
import { SessionBrowser } from "./components/session-browser";
|
|
45
|
+
import { SetupWizard } from "./components/setup-wizard";
|
|
46
|
+
import { DialogStatus } from "./components/dialog-status";
|
|
47
|
+
import { setTerminalTitle, resetTerminalTitle } from "./lib/terminal-title";
|
|
48
|
+
import type { Conversation } from "./lib/history";
|
|
49
|
+
|
|
50
|
+
// ── Types ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface TUIOptions {
|
|
53
|
+
prompt?: string;
|
|
54
|
+
provider: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
apiKey?: string;
|
|
57
|
+
baseUrl?: string;
|
|
58
|
+
workingDir: string;
|
|
59
|
+
mode: string;
|
|
60
|
+
resume?: string;
|
|
61
|
+
continue?: boolean;
|
|
62
|
+
theme: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── App Shell ────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
type Route = "home" | "session";
|
|
68
|
+
type Dialog = "none" | "model" | "command" | "sessions" | "setup" | "help" | "theme" | "status";
|
|
69
|
+
|
|
70
|
+
function AppShell(props: {
|
|
71
|
+
options: TUIOptions;
|
|
72
|
+
agent: AgentRunner;
|
|
73
|
+
registry: ToolRegistry;
|
|
74
|
+
permissionManager: PermissionManager;
|
|
75
|
+
}) {
|
|
76
|
+
const dims = useTerminalDimensions();
|
|
77
|
+
const { theme, themeId, setMode, setThemeId } = useTheme();
|
|
78
|
+
const t = theme;
|
|
79
|
+
|
|
80
|
+
const [route, setRoute] = useState<Route>(props.options.prompt ? "session" : "home");
|
|
81
|
+
const [dialog, setDialog] = useState<Dialog>("none");
|
|
82
|
+
const [status, setStatus] = useState("Ready");
|
|
83
|
+
const [provider, setProvider] = useState(props.options.provider);
|
|
84
|
+
const [model, setModel] = useState(props.options.model || getDefaultModel(props.options.provider) || "default");
|
|
85
|
+
const [workingDir, setWorkingDir] = useState(props.options.workingDir);
|
|
86
|
+
const [tokens, setTokens] = useState<{ input: number; output: number } | undefined>();
|
|
87
|
+
const [contextPercent, setContextPercent] = useState(0);
|
|
88
|
+
const [activeTool, setActiveTool] = useState<string | undefined>();
|
|
89
|
+
const [showSidebar, setShowSidebar] = useState(true);
|
|
90
|
+
|
|
91
|
+
// Mutable refs for agent rebuild
|
|
92
|
+
const agentRef = useRef(props.agent);
|
|
93
|
+
const registryRef = useRef(props.registry);
|
|
94
|
+
const pmRef = useRef(props.permissionManager);
|
|
95
|
+
|
|
96
|
+
// Initial message from home screen input
|
|
97
|
+
const initialMessageRef = useRef<{ text: string; images?: import("@cdoing/ai").ImageAttachment[] } | null>(null);
|
|
98
|
+
|
|
99
|
+
// ── Permission prompt bridge ────────────────────────
|
|
100
|
+
// Store a pending permission resolve callback that the UI can call
|
|
101
|
+
const permissionResolveRef = useRef<((decision: "allow" | "always" | "deny") => void) | null>(null);
|
|
102
|
+
const [pendingPermission, setPendingPermission] = useState<{
|
|
103
|
+
toolName: string;
|
|
104
|
+
message: string;
|
|
105
|
+
} | null>(null);
|
|
106
|
+
|
|
107
|
+
// Wire PermissionManager to show UI prompt
|
|
108
|
+
const requestPermission = useCallback((toolName: string, message: string): Promise<"allow" | "always" | "deny"> => {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
permissionResolveRef.current = resolve;
|
|
111
|
+
setPendingPermission({ toolName, message });
|
|
112
|
+
});
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Set up the prompt function on the permission manager
|
|
116
|
+
pmRef.current.setPromptFn(async (toolName: string, message: string) => {
|
|
117
|
+
const decision = await requestPermission(toolName, message);
|
|
118
|
+
return decision === "always" ? "allow" : decision;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── Agent Rebuild ──────────────────────────────────
|
|
122
|
+
const rebuildAgent = useCallback((newProvider: string, newModel: string, apiKey?: string) => {
|
|
123
|
+
// Resolve API key
|
|
124
|
+
// If apiKey is explicitly "" (empty string), it means logout — skip all fallbacks
|
|
125
|
+
let resolvedKey = apiKey;
|
|
126
|
+
if (apiKey === undefined) {
|
|
127
|
+
const envVar = getApiKeyEnvVar(newProvider);
|
|
128
|
+
if (process.env[envVar]) {
|
|
129
|
+
resolvedKey = process.env[envVar];
|
|
130
|
+
} else {
|
|
131
|
+
try {
|
|
132
|
+
const configPath = path.join(os.homedir(), ".cdoing", "config.json");
|
|
133
|
+
if (fs.existsSync(configPath)) {
|
|
134
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
135
|
+
resolvedKey = config.apiKeys?.[newProvider];
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const modelConfig: Partial<ModelConfig> = {
|
|
142
|
+
provider: newProvider,
|
|
143
|
+
model: newModel,
|
|
144
|
+
apiKey: resolvedKey || undefined,
|
|
145
|
+
baseURL: props.options.baseUrl || undefined,
|
|
146
|
+
temperature: 0,
|
|
147
|
+
maxTokens: 8096,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const newAgent = new AgentRunner(modelConfig, registryRef.current, pmRef.current);
|
|
151
|
+
agentRef.current = newAgent;
|
|
152
|
+
setProvider(newProvider);
|
|
153
|
+
setModel(newModel);
|
|
154
|
+
}, [props.options.baseUrl]);
|
|
155
|
+
|
|
156
|
+
// ── Working Directory Change ────────────────────────
|
|
157
|
+
const handleSetWorkingDir = useCallback((dir: string) => {
|
|
158
|
+
setWorkingDir(dir);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// ── Global Keyboard ──────────────────────────────────
|
|
162
|
+
|
|
163
|
+
useKeyboard((key: any) => {
|
|
164
|
+
// Don't intercept keys when a dialog is open (except escape)
|
|
165
|
+
if (dialog !== "none" && key.name !== "escape") return;
|
|
166
|
+
|
|
167
|
+
// Ctrl+C — graceful quit
|
|
168
|
+
if (key.ctrl && key.name === "c") {
|
|
169
|
+
const cleanup = (globalThis as any).__cdoingCleanup;
|
|
170
|
+
if (cleanup) cleanup();
|
|
171
|
+
else process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
// Ctrl+N — new session
|
|
174
|
+
if (key.ctrl && key.name === "n") {
|
|
175
|
+
setRoute("session");
|
|
176
|
+
setStatus("Ready");
|
|
177
|
+
}
|
|
178
|
+
// Ctrl+P — model picker
|
|
179
|
+
if (key.ctrl && key.name === "p") {
|
|
180
|
+
setDialog((d) => (d === "model" ? "none" : "model"));
|
|
181
|
+
}
|
|
182
|
+
// Ctrl+S — session browser
|
|
183
|
+
if (key.ctrl && key.name === "s") {
|
|
184
|
+
setDialog((d) => (d === "sessions" ? "none" : "sessions"));
|
|
185
|
+
}
|
|
186
|
+
// Ctrl+B — toggle sidebar
|
|
187
|
+
if (key.ctrl && key.name === "b") {
|
|
188
|
+
setShowSidebar((s) => !s);
|
|
189
|
+
}
|
|
190
|
+
// Ctrl+T — theme picker
|
|
191
|
+
if (key.ctrl && key.name === "t") {
|
|
192
|
+
setDialog((d) => (d === "theme" ? "none" : "theme"));
|
|
193
|
+
}
|
|
194
|
+
// Ctrl+X — command palette
|
|
195
|
+
if (key.ctrl && key.name === "x") {
|
|
196
|
+
setDialog((d) => (d === "command" ? "none" : "command"));
|
|
197
|
+
}
|
|
198
|
+
// F1 — help dialog
|
|
199
|
+
if (key.name === "f1") {
|
|
200
|
+
setDialog((d) => (d === "help" ? "none" : "help"));
|
|
201
|
+
}
|
|
202
|
+
// Escape — close any dialog
|
|
203
|
+
if (key.name === "escape") {
|
|
204
|
+
setDialog("none");
|
|
205
|
+
}
|
|
206
|
+
}, {});
|
|
207
|
+
|
|
208
|
+
// ── Session Resume Handler ────────────────────────
|
|
209
|
+
const handleResumeSession = useCallback((_conv: Conversation) => {
|
|
210
|
+
// TODO: restore conversation messages into agent history
|
|
211
|
+
setRoute("session");
|
|
212
|
+
setDialog("none");
|
|
213
|
+
setStatus("Ready");
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<box width={dims.width} height={dims.height} flexDirection="column">
|
|
218
|
+
{/* Header bar */}
|
|
219
|
+
<box height={1} flexDirection="row" paddingX={1} flexShrink={0}>
|
|
220
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>{"cdoing"}</text>
|
|
221
|
+
<text fg={t.border}>{" │ "}</text>
|
|
222
|
+
<text fg={t.textMuted}>{model}</text>
|
|
223
|
+
<text fg={t.border}>{" │ "}</text>
|
|
224
|
+
<text fg={status === "Error" ? t.error : status === "Processing..." ? t.warning : t.success}>
|
|
225
|
+
{status}
|
|
226
|
+
</text>
|
|
227
|
+
</box>
|
|
228
|
+
|
|
229
|
+
{/* Session header (only in session route) */}
|
|
230
|
+
{route === "session" && (
|
|
231
|
+
<SessionHeader
|
|
232
|
+
title="Session"
|
|
233
|
+
provider={provider}
|
|
234
|
+
model={model}
|
|
235
|
+
tokens={tokens}
|
|
236
|
+
contextPercent={contextPercent}
|
|
237
|
+
status={status}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{/* Separator */}
|
|
242
|
+
<box height={1} flexShrink={0}>
|
|
243
|
+
<text fg={t.border}>{"─".repeat(Math.max(dims.width, 40))}</text>
|
|
244
|
+
</box>
|
|
245
|
+
|
|
246
|
+
{/* Main content area with optional sidebar */}
|
|
247
|
+
<box flexDirection="row" flexGrow={1}>
|
|
248
|
+
<SDKProvider
|
|
249
|
+
value={{
|
|
250
|
+
agent: agentRef.current,
|
|
251
|
+
registry: registryRef.current,
|
|
252
|
+
permissionManager: pmRef.current,
|
|
253
|
+
workingDir,
|
|
254
|
+
provider,
|
|
255
|
+
model,
|
|
256
|
+
requestPermission,
|
|
257
|
+
rebuildAgent,
|
|
258
|
+
setWorkingDir: handleSetWorkingDir,
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<box flexGrow={1} flexDirection="column">
|
|
262
|
+
{dialog === "sessions" ? (
|
|
263
|
+
<SessionBrowser
|
|
264
|
+
onResume={handleResumeSession}
|
|
265
|
+
onClose={() => setDialog("none")}
|
|
266
|
+
/>
|
|
267
|
+
) : dialog === "setup" ? (
|
|
268
|
+
<SetupWizard
|
|
269
|
+
onComplete={(config) => {
|
|
270
|
+
rebuildAgent(config.provider, config.model, config.apiKey);
|
|
271
|
+
setDialog("none");
|
|
272
|
+
}}
|
|
273
|
+
onClose={() => setDialog("none")}
|
|
274
|
+
/>
|
|
275
|
+
) : route === "home" ? (
|
|
276
|
+
<Home
|
|
277
|
+
provider={provider}
|
|
278
|
+
model={model}
|
|
279
|
+
workingDir={workingDir}
|
|
280
|
+
themeId={themeId}
|
|
281
|
+
onSubmit={(text, images) => {
|
|
282
|
+
initialMessageRef.current = { text, images };
|
|
283
|
+
setRoute("session");
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
) : (
|
|
287
|
+
<SessionView
|
|
288
|
+
onStatus={setStatus}
|
|
289
|
+
onTokens={(i, o) => setTokens({ input: i, output: o })}
|
|
290
|
+
onActiveTool={setActiveTool}
|
|
291
|
+
onContextPercent={setContextPercent}
|
|
292
|
+
onOpenDialog={(d) => setDialog(d as Dialog)}
|
|
293
|
+
initialMessage={initialMessageRef.current}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</box>
|
|
297
|
+
</SDKProvider>
|
|
298
|
+
|
|
299
|
+
{/* Sidebar (right panel) */}
|
|
300
|
+
{showSidebar && (
|
|
301
|
+
<Sidebar
|
|
302
|
+
provider={provider}
|
|
303
|
+
model={model}
|
|
304
|
+
workingDir={workingDir}
|
|
305
|
+
tokens={tokens}
|
|
306
|
+
contextPercent={contextPercent}
|
|
307
|
+
activeTool={activeTool}
|
|
308
|
+
status={status}
|
|
309
|
+
themeId={themeId}
|
|
310
|
+
/>
|
|
311
|
+
)}
|
|
312
|
+
</box>
|
|
313
|
+
|
|
314
|
+
{/* Separator */}
|
|
315
|
+
<box height={1} flexShrink={0}>
|
|
316
|
+
<text fg={t.border}>{"─".repeat(Math.max(dims.width, 40))}</text>
|
|
317
|
+
</box>
|
|
318
|
+
|
|
319
|
+
{/* Footer: session footer in session route, status bar always */}
|
|
320
|
+
{route === "session" ? (
|
|
321
|
+
<SessionFooter
|
|
322
|
+
workingDir={workingDir}
|
|
323
|
+
isProcessing={status === "Processing..."}
|
|
324
|
+
/>
|
|
325
|
+
) : (
|
|
326
|
+
<StatusBar
|
|
327
|
+
provider={provider}
|
|
328
|
+
model={model}
|
|
329
|
+
mode={props.options.mode}
|
|
330
|
+
workingDir={workingDir}
|
|
331
|
+
tokens={tokens}
|
|
332
|
+
contextPercent={contextPercent}
|
|
333
|
+
activeTool={activeTool}
|
|
334
|
+
isProcessing={status === "Processing..."}
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* Model picker dialog (overlay) */}
|
|
339
|
+
{dialog === "model" && (
|
|
340
|
+
<DialogModel
|
|
341
|
+
provider={provider}
|
|
342
|
+
currentModel={model}
|
|
343
|
+
onSelect={(m) => {
|
|
344
|
+
rebuildAgent(provider, m);
|
|
345
|
+
setDialog("none");
|
|
346
|
+
}}
|
|
347
|
+
onClose={() => setDialog("none")}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* Command palette dialog (overlay) */}
|
|
352
|
+
{dialog === "command" && (
|
|
353
|
+
<DialogCommand
|
|
354
|
+
onSelect={(commandId) => {
|
|
355
|
+
setDialog("none");
|
|
356
|
+
switch (commandId) {
|
|
357
|
+
// Session
|
|
358
|
+
case "session:new":
|
|
359
|
+
setRoute("session");
|
|
360
|
+
setStatus("Ready");
|
|
361
|
+
break;
|
|
362
|
+
case "session:browse":
|
|
363
|
+
setDialog("sessions");
|
|
364
|
+
break;
|
|
365
|
+
case "session:clear":
|
|
366
|
+
setRoute("session");
|
|
367
|
+
setStatus("Ready");
|
|
368
|
+
break;
|
|
369
|
+
// Model
|
|
370
|
+
case "model:switch":
|
|
371
|
+
setDialog("model");
|
|
372
|
+
break;
|
|
373
|
+
// Theme
|
|
374
|
+
case "theme:dark":
|
|
375
|
+
setMode("dark");
|
|
376
|
+
break;
|
|
377
|
+
case "theme:light":
|
|
378
|
+
setMode("light");
|
|
379
|
+
break;
|
|
380
|
+
case "theme:picker":
|
|
381
|
+
setDialog("theme");
|
|
382
|
+
break;
|
|
383
|
+
// Display
|
|
384
|
+
case "display:sidebar":
|
|
385
|
+
setShowSidebar((s) => !s);
|
|
386
|
+
break;
|
|
387
|
+
case "display:timestamps":
|
|
388
|
+
case "display:thinking":
|
|
389
|
+
// Display toggles — extend as needed
|
|
390
|
+
break;
|
|
391
|
+
// System
|
|
392
|
+
case "system:status":
|
|
393
|
+
setDialog("status");
|
|
394
|
+
break;
|
|
395
|
+
case "system:help":
|
|
396
|
+
setDialog("help");
|
|
397
|
+
break;
|
|
398
|
+
case "system:doctor":
|
|
399
|
+
setStatus("Doctor");
|
|
400
|
+
break;
|
|
401
|
+
case "system:setup":
|
|
402
|
+
setDialog("setup");
|
|
403
|
+
break;
|
|
404
|
+
case "system:exit": {
|
|
405
|
+
const exit = (globalThis as any).__cdoingCleanup;
|
|
406
|
+
if (exit) exit();
|
|
407
|
+
else process.exit(0);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}}
|
|
412
|
+
onClose={() => setDialog("none")}
|
|
413
|
+
/>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Help dialog (overlay) */}
|
|
417
|
+
{dialog === "help" && (
|
|
418
|
+
<DialogHelp
|
|
419
|
+
onClose={() => setDialog("none")}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Theme picker dialog (overlay) */}
|
|
424
|
+
{dialog === "theme" && (
|
|
425
|
+
<DialogTheme
|
|
426
|
+
onClose={() => setDialog("none")}
|
|
427
|
+
/>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
{/* Status dialog (overlay) */}
|
|
431
|
+
{dialog === "status" && (
|
|
432
|
+
<DialogStatus onClose={() => setDialog("none")} />
|
|
433
|
+
)}
|
|
434
|
+
</box>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Entry Point ──────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
export async function startTUI(options: TUIOptions): Promise<void> {
|
|
441
|
+
// Initialize core services
|
|
442
|
+
const registry = new ToolRegistry();
|
|
443
|
+
const permMode = options.mode === "auto" ? PermissionMode.BYPASS
|
|
444
|
+
: options.mode === "auto-edit" ? PermissionMode.ACCEPT_EDITS
|
|
445
|
+
: PermissionMode.DEFAULT;
|
|
446
|
+
const pm = new PermissionManager(permMode, options.workingDir);
|
|
447
|
+
|
|
448
|
+
// Permission prompt will be wired up via React state in AppShell
|
|
449
|
+
// Set a temporary default — will be overridden once React mounts
|
|
450
|
+
pm.setPromptFn(async (_toolName, _message) => {
|
|
451
|
+
return "allow";
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await registerAllTools(registry, {
|
|
455
|
+
workingDir: options.workingDir,
|
|
456
|
+
permissionManager: pm,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Resolve API key: flag → env var → stored config (~/.cdoing/config.json)
|
|
460
|
+
let resolvedApiKey = options.apiKey;
|
|
461
|
+
let resolvedProvider = options.provider;
|
|
462
|
+
let resolvedModel = options.model;
|
|
463
|
+
let resolvedBaseUrl = options.baseUrl;
|
|
464
|
+
|
|
465
|
+
if (!resolvedApiKey) {
|
|
466
|
+
// Load stored config
|
|
467
|
+
const configPath = path.join(os.homedir(), ".cdoing", "config.json");
|
|
468
|
+
let storedConfig: Record<string, any> = {};
|
|
469
|
+
try {
|
|
470
|
+
if (fs.existsSync(configPath)) {
|
|
471
|
+
storedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
472
|
+
}
|
|
473
|
+
} catch {}
|
|
474
|
+
|
|
475
|
+
// Apply stored provider/model/baseUrl if not set via flags
|
|
476
|
+
if (resolvedProvider === "anthropic" && storedConfig.provider) {
|
|
477
|
+
resolvedProvider = storedConfig.provider;
|
|
478
|
+
}
|
|
479
|
+
if (!resolvedModel && storedConfig.model) {
|
|
480
|
+
resolvedModel = storedConfig.model;
|
|
481
|
+
}
|
|
482
|
+
if (!resolvedBaseUrl && storedConfig.baseUrl) {
|
|
483
|
+
resolvedBaseUrl = storedConfig.baseUrl;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check env var
|
|
487
|
+
const envVar = getApiKeyEnvVar(resolvedProvider);
|
|
488
|
+
if (process.env[envVar]) {
|
|
489
|
+
resolvedApiKey = process.env[envVar];
|
|
490
|
+
}
|
|
491
|
+
// Check stored API keys
|
|
492
|
+
else if (storedConfig.apiKeys?.[resolvedProvider]) {
|
|
493
|
+
resolvedApiKey = storedConfig.apiKeys[resolvedProvider];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Build model config
|
|
498
|
+
const modelConfig: Partial<ModelConfig> = {
|
|
499
|
+
provider: resolvedProvider,
|
|
500
|
+
model: resolvedModel || undefined,
|
|
501
|
+
apiKey: resolvedApiKey || undefined,
|
|
502
|
+
baseURL: resolvedBaseUrl || undefined,
|
|
503
|
+
temperature: 0,
|
|
504
|
+
maxTokens: 8096,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Create agent
|
|
508
|
+
const agent = new AgentRunner(modelConfig, registry, pm);
|
|
509
|
+
|
|
510
|
+
// Detect terminal background color before rendering (async OSC 11 query)
|
|
511
|
+
let detectedMode: "dark" | "light" | undefined;
|
|
512
|
+
if (options.theme === "auto") {
|
|
513
|
+
detectedMode = await detectTerminalTheme();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Set terminal background BEFORE clearing so it fills the entire screen
|
|
517
|
+
const resolvedMode: "dark" | "light" = options.theme === "light" ? "light"
|
|
518
|
+
: options.theme === "auto" ? (detectedMode || "dark")
|
|
519
|
+
: "dark";
|
|
520
|
+
const initialColors = getThemeColors("default", resolvedMode);
|
|
521
|
+
setTerminalBackground(initialColors.bg);
|
|
522
|
+
|
|
523
|
+
console.clear();
|
|
524
|
+
|
|
525
|
+
// Set terminal title on mount
|
|
526
|
+
setTerminalTitle("cdoing");
|
|
527
|
+
|
|
528
|
+
const renderer = await createCliRenderer({
|
|
529
|
+
useMouse: true,
|
|
530
|
+
exitOnCtrlC: false,
|
|
531
|
+
});
|
|
532
|
+
const root = createRoot(renderer);
|
|
533
|
+
root.render(
|
|
534
|
+
<ThemeProvider mode={options.theme} detectedMode={detectedMode} syncTerminalBg>
|
|
535
|
+
<ToastProvider>
|
|
536
|
+
<AppShell
|
|
537
|
+
options={{ ...options, provider: resolvedProvider, model: resolvedModel || undefined }}
|
|
538
|
+
agent={agent}
|
|
539
|
+
registry={registry}
|
|
540
|
+
permissionManager={pm}
|
|
541
|
+
/>
|
|
542
|
+
</ToastProvider>
|
|
543
|
+
</ThemeProvider>
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Graceful cleanup: unmount React, destroy renderer, restore terminal
|
|
547
|
+
let isCleaningUp = false;
|
|
548
|
+
const cleanup = () => {
|
|
549
|
+
if (isCleaningUp) return;
|
|
550
|
+
isCleaningUp = true;
|
|
551
|
+
try { root.unmount(); } catch {}
|
|
552
|
+
try { renderer.destroy(); } catch {}
|
|
553
|
+
resetTerminalTitle();
|
|
554
|
+
restoreTerminalBackground();
|
|
555
|
+
process.exit(0);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
process.on("SIGINT", cleanup);
|
|
559
|
+
process.on("SIGTERM", cleanup);
|
|
560
|
+
|
|
561
|
+
// Expose cleanup globally so the keyboard handler can use it
|
|
562
|
+
(globalThis as any).__cdoingCleanup = cleanup;
|
|
563
|
+
|
|
564
|
+
// Keep alive
|
|
565
|
+
await new Promise(() => {});
|
|
566
|
+
}
|