@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,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
+ }