@cdoing/opentuicli 0.1.2 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cdoing/opentuicli",
3
- "version": "0.1.2",
3
+ "version": "0.1.18",
4
4
  "description": "OpenTUI-based terminal interface for cdoing agent (inspired by opencode's TUI)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,8 @@
22
22
  "@opentui/react": "0.1.87",
23
23
  "chalk": "^4.1.2",
24
24
  "commander": "^13.1.0",
25
- "react": "^19.1.0"
25
+ "react": "^19.1.0",
26
+ "zustand": "^5.0.12"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/node": "^22.13.10",
@@ -30,5 +31,6 @@
30
31
  "esbuild": "^0.25.0",
31
32
  "ts-node": "^10.9.2",
32
33
  "typescript": "^5.8.2"
33
- }
34
+ },
35
+ "license": "Apache-2.0"
34
36
  }
package/src/app.tsx CHANGED
@@ -8,7 +8,7 @@
8
8
  * - Session browser overlay (Ctrl+S)
9
9
  * - Setup wizard overlay (/setup)
10
10
  * - Keyboard-driven navigation
11
- * - Model picker dialog (Ctrl+P)
11
+ * - Command palette (Ctrl+P)
12
12
  * - Theme support (dark/light/auto)
13
13
  * - Status bar with token counts and context %
14
14
  */
@@ -17,13 +17,18 @@ import * as fs from "fs";
17
17
  import * as path from "path";
18
18
  import * as os from "os";
19
19
  import { createRoot, useKeyboard, useTerminalDimensions } from "@opentui/react";
20
- import { createCliRenderer, TextAttributes } from "@opentui/core";
20
+ import { createCliRenderer, TextAttributes, RGBA } from "@opentui/core";
21
21
  import { useState, useRef, useCallback } from "react";
22
22
  import {
23
23
  ToolRegistry,
24
24
  PermissionManager,
25
25
  PermissionMode,
26
+ ProcessManager,
27
+ TodoStore,
28
+ MemoryStore,
26
29
  registerAllTools,
30
+ resolveOAuthToken,
31
+ supportsOAuth,
27
32
  } from "@cdoing/core";
28
33
  import { AgentRunner, getDefaultModel, getApiKeyEnvVar } from "@cdoing/ai";
29
34
  import type { ModelConfig } from "@cdoing/ai";
@@ -31,6 +36,7 @@ import type { ModelConfig } from "@cdoing/ai";
31
36
  import { ThemeProvider, useTheme, detectTerminalTheme, restoreTerminalBackground, getThemeColors, setTerminalBackground } from "./context/theme";
32
37
  import { SDKProvider } from "./context/sdk";
33
38
  import { ToastProvider } from "./components/toast";
39
+ import { useSettingsStore } from "./store/settings";
34
40
  import { Home } from "./routes/home";
35
41
  import { SessionView } from "./routes/session";
36
42
  import { StatusBar } from "./components/status-bar";
@@ -74,19 +80,32 @@ function AppShell(props: {
74
80
  permissionManager: PermissionManager;
75
81
  }) {
76
82
  const dims = useTerminalDimensions();
77
- const { theme, themeId, setMode, setThemeId } = useTheme();
83
+ const { theme, themeId, customBg, setMode, setThemeId } = useTheme();
78
84
  const t = theme;
79
85
 
80
86
  const [route, setRoute] = useState<Route>(props.options.prompt ? "session" : "home");
81
87
  const [dialog, setDialog] = useState<Dialog>("none");
82
88
  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
89
  const [workingDir, setWorkingDir] = useState(props.options.workingDir);
86
90
  const [tokens, setTokens] = useState<{ input: number; output: number } | undefined>();
87
91
  const [contextPercent, setContextPercent] = useState(0);
88
92
  const [activeTool, setActiveTool] = useState<string | undefined>();
89
- const [showSidebar, setShowSidebar] = useState(true);
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
+ }, []);
90
109
 
91
110
  // Mutable refs for agent rebuild
92
111
  const agentRef = useRef(props.agent);
@@ -119,11 +138,34 @@ function AppShell(props: {
119
138
  });
120
139
 
121
140
  // ── Agent Rebuild ──────────────────────────────────
122
- const rebuildAgent = useCallback((newProvider: string, newModel: string, apiKey?: string) => {
123
- // Resolve API key
141
+ const rebuildAgent = useCallback((newProvider: string, newModel: string, apiKey?: string, oauthToken?: string) => {
142
+ // Resolve API key or OAuth token
124
143
  // If apiKey is explicitly "" (empty string), it means logout — skip all fallbacks
125
144
  let resolvedKey = apiKey;
126
- if (apiKey === undefined) {
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
+
127
169
  const envVar = getApiKeyEnvVar(newProvider);
128
170
  if (process.env[envVar]) {
129
171
  resolvedKey = process.env[envVar];
@@ -142,6 +184,7 @@ function AppShell(props: {
142
184
  provider: newProvider,
143
185
  model: newModel,
144
186
  apiKey: resolvedKey || undefined,
187
+ oauthToken: resolvedOAuthToken || undefined,
145
188
  baseURL: props.options.baseUrl || undefined,
146
189
  temperature: 0,
147
190
  maxTokens: 8096,
@@ -161,8 +204,20 @@ function AppShell(props: {
161
204
  // ── Global Keyboard ──────────────────────────────────
162
205
 
163
206
  useKeyboard((key: any) => {
164
- // Don't intercept keys when a dialog is open (except escape)
165
- if (dialog !== "none" && key.name !== "escape") return;
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
+ }
166
221
 
167
222
  // Ctrl+C — graceful quit
168
223
  if (key.ctrl && key.name === "c") {
@@ -175,9 +230,9 @@ function AppShell(props: {
175
230
  setRoute("session");
176
231
  setStatus("Ready");
177
232
  }
178
- // Ctrl+P — model picker
233
+ // Ctrl+P — command palette
179
234
  if (key.ctrl && key.name === "p") {
180
- setDialog((d) => (d === "model" ? "none" : "model"));
235
+ setDialog((d) => (d === "command" ? "none" : "command"));
181
236
  }
182
237
  // Ctrl+S — session browser
183
238
  if (key.ctrl && key.name === "s") {
@@ -185,15 +240,15 @@ function AppShell(props: {
185
240
  }
186
241
  // Ctrl+B — toggle sidebar
187
242
  if (key.ctrl && key.name === "b") {
188
- setShowSidebar((s) => !s);
243
+ setSidebarMode(sidebarMode === "hide" ? "show" : sidebarMode === "show" ? "hide" : showSidebar ? "hide" : "show");
189
244
  }
190
245
  // Ctrl+T — theme picker
191
246
  if (key.ctrl && key.name === "t") {
192
247
  setDialog((d) => (d === "theme" ? "none" : "theme"));
193
248
  }
194
- // Ctrl+Xcommand palette
195
- if (key.ctrl && key.name === "x") {
196
- setDialog((d) => (d === "command" ? "none" : "command"));
249
+ // Ctrl+Omodel picker (Ctrl+M = Enter in terminals)
250
+ if (key.ctrl && key.name === "o") {
251
+ setDialog((d) => (d === "model" ? "none" : "model"));
197
252
  }
198
253
  // F1 — help dialog
199
254
  if (key.name === "f1") {
@@ -214,9 +269,9 @@ function AppShell(props: {
214
269
  }, []);
215
270
 
216
271
  return (
217
- <box width={dims.width} height={dims.height} flexDirection="column">
272
+ <box width={dims.width} height={dims.height} flexDirection="column" backgroundColor={customBg ? RGBA.fromHex(customBg) : t.bg}>
218
273
  {/* Header bar */}
219
- <box height={1} flexDirection="row" paddingX={1} flexShrink={0}>
274
+ <box height={1} flexDirection="row" paddingX={1} flexShrink={0} backgroundColor={t.bgSubtle}>
220
275
  <text fg={t.primary} attributes={TextAttributes.BOLD}>{"cdoing"}</text>
221
276
  <text fg={t.border}>{" │ "}</text>
222
277
  <text fg={t.textMuted}>{model}</text>
@@ -267,7 +322,7 @@ function AppShell(props: {
267
322
  ) : dialog === "setup" ? (
268
323
  <SetupWizard
269
324
  onComplete={(config) => {
270
- rebuildAgent(config.provider, config.model, config.apiKey);
325
+ rebuildAgent(config.provider, config.model, config.apiKey, config.oauthToken);
271
326
  setDialog("none");
272
327
  }}
273
328
  onClose={() => setDialog("none")}
@@ -291,11 +346,19 @@ function AppShell(props: {
291
346
  onContextPercent={setContextPercent}
292
347
  onOpenDialog={(d) => setDialog(d as Dialog)}
293
348
  initialMessage={initialMessageRef.current}
349
+ dialogOpen={dialog !== "none"}
294
350
  />
295
351
  )}
296
352
  </box>
297
353
  </SDKProvider>
298
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
+
299
362
  {/* Sidebar (right panel) */}
300
363
  {showSidebar && (
301
364
  <Sidebar
@@ -368,6 +431,7 @@ function AppShell(props: {
368
431
  break;
369
432
  // Model
370
433
  case "model:switch":
434
+ case "model:provider":
371
435
  setDialog("model");
372
436
  break;
373
437
  // Theme
@@ -382,11 +446,13 @@ function AppShell(props: {
382
446
  break;
383
447
  // Display
384
448
  case "display:sidebar":
385
- setShowSidebar((s) => !s);
449
+ setSidebarMode(sidebarMode === "hide" ? "show" : sidebarMode === "show" ? "hide" : showSidebar ? "hide" : "show");
386
450
  break;
387
- case "display:timestamps":
388
- case "display:thinking":
389
- // Display toggles — extend as needed
451
+ // Tools (dispatch as slash commands into session)
452
+ case "tool:shell":
453
+ case "tool:search":
454
+ case "tool:tree":
455
+ setRoute("session");
390
456
  break;
391
457
  // System
392
458
  case "system:status":
@@ -431,6 +497,96 @@ function AppShell(props: {
431
497
  {dialog === "status" && (
432
498
  <DialogStatus onClose={() => setDialog("none")} />
433
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>
434
590
  </box>
435
591
  );
436
592
  }
@@ -451,13 +607,25 @@ export async function startTUI(options: TUIOptions): Promise<void> {
451
607
  return "allow";
452
608
  });
453
609
 
610
+ const processManager = new ProcessManager();
611
+ const todoStore = new TodoStore();
612
+ const memoryStore = new MemoryStore(options.workingDir);
454
613
  await registerAllTools(registry, {
455
614
  workingDir: options.workingDir,
456
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
+ },
457
624
  });
458
625
 
459
- // Resolve API key: flag → env var → stored config (~/.cdoing/config.json)
626
+ // Resolve API key: flag → env var → stored config → OAuth token
460
627
  let resolvedApiKey = options.apiKey;
628
+ let resolvedOAuthToken: string | undefined;
461
629
  let resolvedProvider = options.provider;
462
630
  let resolvedModel = options.model;
463
631
  let resolvedBaseUrl = options.baseUrl;
@@ -492,13 +660,39 @@ export async function startTUI(options: TUIOptions): Promise<void> {
492
660
  else if (storedConfig.apiKeys?.[resolvedProvider]) {
493
661
  resolvedApiKey = storedConfig.apiKeys[resolvedProvider];
494
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");
495
684
  }
496
685
 
497
- // Build model config
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)
498
691
  const modelConfig: Partial<ModelConfig> = {
499
- provider: resolvedProvider,
500
- model: resolvedModel || undefined,
692
+ provider: effectiveProvider,
693
+ model: effectiveModel || undefined,
501
694
  apiKey: resolvedApiKey || undefined,
695
+ oauthToken: resolvedOAuthToken || undefined,
502
696
  baseURL: resolvedBaseUrl || undefined,
503
697
  temperature: 0,
504
698
  maxTokens: 8096,
@@ -517,9 +711,24 @@ export async function startTUI(options: TUIOptions): Promise<void> {
517
711
  const resolvedMode: "dark" | "light" = options.theme === "light" ? "light"
518
712
  : options.theme === "auto" ? (detectedMode || "dark")
519
713
  : "dark";
520
- const initialColors = getThemeColors("default", resolvedMode);
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);
521
725
  setTerminalBackground(initialColors.bg);
522
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
+
523
732
  console.clear();
524
733
 
525
734
  // Set terminal title on mount
@@ -530,17 +739,29 @@ export async function startTUI(options: TUIOptions): Promise<void> {
530
739
  exitOnCtrlC: false,
531
740
  });
532
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
+
533
752
  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>
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>
544
765
  );
545
766
 
546
767
  // Graceful cleanup: unmount React, destroy renderer, restore terminal