@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,532 @@
1
+ /**
2
+ * Theme Context — Multi-theme system for the TUI
3
+ *
4
+ * Supports 15+ built-in themes inspired by popular editor themes.
5
+ * Each theme has dark and light variants with full color palettes.
6
+ *
7
+ * Detection:
8
+ * 1. OSC 11 escape sequence (queries terminal directly)
9
+ * 2. COLORFGBG environment variable (fallback)
10
+ * 3. Defaults to dark mode
11
+ */
12
+
13
+ import { RGBA, rgbToHex, SyntaxStyle } from "@opentui/core";
14
+ import { createContext, useContext, useState, useEffect, useRef, useMemo } from "react";
15
+ import type { ReactNode } from "react";
16
+
17
+ export interface Theme {
18
+ text: RGBA;
19
+ textMuted: RGBA;
20
+ textDim: RGBA;
21
+ primary: RGBA;
22
+ secondary: RGBA;
23
+ success: RGBA;
24
+ error: RGBA;
25
+ warning: RGBA;
26
+ info: RGBA;
27
+ border: RGBA;
28
+ borderFocused: RGBA;
29
+ bg: RGBA;
30
+ bgSubtle: RGBA;
31
+ // Roles
32
+ userText: RGBA;
33
+ assistantText: RGBA;
34
+ systemText: RGBA;
35
+ toolText: RGBA;
36
+ // Tool status
37
+ toolRunning: RGBA;
38
+ toolDone: RGBA;
39
+ toolError: RGBA;
40
+ // Diff
41
+ diffAdd: RGBA;
42
+ diffRemove: RGBA;
43
+ diffHunk: RGBA;
44
+ }
45
+
46
+ // ── Theme Definitions ─────────────────────────────────────
47
+
48
+ export interface ThemeDef {
49
+ name: string;
50
+ dark: Theme;
51
+ light: Theme;
52
+ }
53
+
54
+ function t(hex: string): RGBA {
55
+ return RGBA.fromHex(hex);
56
+ }
57
+
58
+ /** Helper to build a full theme from a minimal palette */
59
+ function buildTheme(p: {
60
+ bg: string; bgSubtle: string; text: string; textMuted: string; textDim: string;
61
+ primary: string; secondary: string; success: string; error: string;
62
+ warning: string; info: string; border: string;
63
+ diffAdd?: string; diffRemove?: string;
64
+ }): Theme {
65
+ return {
66
+ text: t(p.text), textMuted: t(p.textMuted), textDim: t(p.textDim),
67
+ primary: t(p.primary), secondary: t(p.secondary),
68
+ success: t(p.success), error: t(p.error), warning: t(p.warning), info: t(p.info),
69
+ border: t(p.border), borderFocused: t(p.primary),
70
+ bg: t(p.bg), bgSubtle: t(p.bgSubtle),
71
+ userText: t(p.success), assistantText: t(p.text),
72
+ systemText: t(p.warning), toolText: t(p.textMuted),
73
+ toolRunning: t(p.warning), toolDone: t(p.success), toolError: t(p.error),
74
+ diffAdd: t(p.diffAdd || p.success), diffRemove: t(p.diffRemove || p.error),
75
+ diffHunk: t(p.info),
76
+ };
77
+ }
78
+
79
+ // ── Built-in Themes ───────────────────────────────────────
80
+
81
+ export const THEMES: Record<string, ThemeDef> = {
82
+ default: {
83
+ name: "Default",
84
+ dark: buildTheme({
85
+ bg: "#000000", bgSubtle: "#111111", text: "#e5e7eb", textMuted: "#9ca3af", textDim: "#6b7280",
86
+ primary: "#06b6d4", secondary: "#8b5cf6", success: "#22c55e", error: "#ef4444",
87
+ warning: "#eab308", info: "#3b82f6", border: "#374151",
88
+ }),
89
+ light: buildTheme({
90
+ bg: "#ffffff", bgSubtle: "#f3f4f6", text: "#1f2937", textMuted: "#6b7280", textDim: "#9ca3af",
91
+ primary: "#0891b2", secondary: "#7c3aed", success: "#16a34a", error: "#dc2626",
92
+ warning: "#ca8a04", info: "#2563eb", border: "#d1d5db",
93
+ }),
94
+ },
95
+ catppuccin: {
96
+ name: "Catppuccin",
97
+ dark: buildTheme({
98
+ bg: "#1e1e2e", bgSubtle: "#313244", text: "#cdd6f4", textMuted: "#a6adc8", textDim: "#6c7086",
99
+ primary: "#89b4fa", secondary: "#cba6f7", success: "#a6e3a1", error: "#f38ba8",
100
+ warning: "#f9e2af", info: "#74c7ec", border: "#45475a",
101
+ }),
102
+ light: buildTheme({
103
+ bg: "#eff1f5", bgSubtle: "#e6e9ef", text: "#4c4f69", textMuted: "#6c6f85", textDim: "#9ca0b0",
104
+ primary: "#1e66f5", secondary: "#8839ef", success: "#40a02b", error: "#d20f39",
105
+ warning: "#df8e1d", info: "#04a5e5", border: "#ccd0da",
106
+ }),
107
+ },
108
+ dracula: {
109
+ name: "Dracula",
110
+ dark: buildTheme({
111
+ bg: "#282a36", bgSubtle: "#44475a", text: "#f8f8f2", textMuted: "#6272a4", textDim: "#44475a",
112
+ primary: "#bd93f9", secondary: "#ff79c6", success: "#50fa7b", error: "#ff5555",
113
+ warning: "#f1fa8c", info: "#8be9fd", border: "#44475a",
114
+ }),
115
+ light: buildTheme({
116
+ bg: "#f8f8f2", bgSubtle: "#e6e6e6", text: "#282a36", textMuted: "#6272a4", textDim: "#999999",
117
+ primary: "#7c3aed", secondary: "#d946ef", success: "#16a34a", error: "#dc2626",
118
+ warning: "#ca8a04", info: "#0891b2", border: "#d1d5db",
119
+ }),
120
+ },
121
+ nord: {
122
+ name: "Nord",
123
+ dark: buildTheme({
124
+ bg: "#2e3440", bgSubtle: "#3b4252", text: "#eceff4", textMuted: "#d8dee9", textDim: "#4c566a",
125
+ primary: "#88c0d0", secondary: "#b48ead", success: "#a3be8c", error: "#bf616a",
126
+ warning: "#ebcb8b", info: "#81a1c1", border: "#434c5e",
127
+ }),
128
+ light: buildTheme({
129
+ bg: "#eceff4", bgSubtle: "#e5e9f0", text: "#2e3440", textMuted: "#4c566a", textDim: "#7b88a1",
130
+ primary: "#5e81ac", secondary: "#b48ead", success: "#a3be8c", error: "#bf616a",
131
+ warning: "#ebcb8b", info: "#81a1c1", border: "#d8dee9",
132
+ }),
133
+ },
134
+ tokyonight: {
135
+ name: "Tokyo Night",
136
+ dark: buildTheme({
137
+ bg: "#1a1b26", bgSubtle: "#24283b", text: "#c0caf5", textMuted: "#a9b1d6", textDim: "#565f89",
138
+ primary: "#7aa2f7", secondary: "#bb9af7", success: "#9ece6a", error: "#f7768e",
139
+ warning: "#e0af68", info: "#7dcfff", border: "#3b4261",
140
+ }),
141
+ light: buildTheme({
142
+ bg: "#d5d6db", bgSubtle: "#cbccd1", text: "#343b58", textMuted: "#565a6e", textDim: "#9699a3",
143
+ primary: "#34548a", secondary: "#5a4a78", success: "#485e30", error: "#8c4351",
144
+ warning: "#8f5e15", info: "#0f4b6e", border: "#b4b5b9",
145
+ }),
146
+ },
147
+ gruvbox: {
148
+ name: "Gruvbox",
149
+ dark: buildTheme({
150
+ bg: "#282828", bgSubtle: "#3c3836", text: "#ebdbb2", textMuted: "#a89984", textDim: "#665c54",
151
+ primary: "#fabd2f", secondary: "#d3869b", success: "#b8bb26", error: "#fb4934",
152
+ warning: "#fe8019", info: "#83a598", border: "#504945",
153
+ }),
154
+ light: buildTheme({
155
+ bg: "#fbf1c7", bgSubtle: "#f2e5bc", text: "#3c3836", textMuted: "#7c6f64", textDim: "#a89984",
156
+ primary: "#b57614", secondary: "#8f3f71", success: "#79740e", error: "#9d0006",
157
+ warning: "#af3a03", info: "#427b58", border: "#d5c4a1",
158
+ }),
159
+ },
160
+ rosepine: {
161
+ name: "Rosé Pine",
162
+ dark: buildTheme({
163
+ bg: "#191724", bgSubtle: "#1f1d2e", text: "#e0def4", textMuted: "#908caa", textDim: "#6e6a86",
164
+ primary: "#c4a7e7", secondary: "#ebbcba", success: "#31748f", error: "#eb6f92",
165
+ warning: "#f6c177", info: "#9ccfd8", border: "#26233a",
166
+ }),
167
+ light: buildTheme({
168
+ bg: "#faf4ed", bgSubtle: "#f2e9e1", text: "#575279", textMuted: "#797593", textDim: "#9893a5",
169
+ primary: "#907aa9", secondary: "#d7827e", success: "#286983", error: "#b4637a",
170
+ warning: "#ea9d34", info: "#56949f", border: "#dfdad9",
171
+ }),
172
+ },
173
+ monokai: {
174
+ name: "Monokai",
175
+ dark: buildTheme({
176
+ bg: "#272822", bgSubtle: "#3e3d32", text: "#f8f8f2", textMuted: "#75715e", textDim: "#49483e",
177
+ primary: "#66d9ef", secondary: "#ae81ff", success: "#a6e22e", error: "#f92672",
178
+ warning: "#e6db74", info: "#66d9ef", border: "#49483e",
179
+ }),
180
+ light: buildTheme({
181
+ bg: "#fafafa", bgSubtle: "#f0f0f0", text: "#272822", textMuted: "#75715e", textDim: "#b0b0b0",
182
+ primary: "#0096d1", secondary: "#7c3aed", success: "#629e25", error: "#c4265e",
183
+ warning: "#b5a21d", info: "#0096d1", border: "#d0d0d0",
184
+ }),
185
+ },
186
+ solarized: {
187
+ name: "Solarized",
188
+ dark: buildTheme({
189
+ bg: "#002b36", bgSubtle: "#073642", text: "#839496", textMuted: "#657b83", textDim: "#586e75",
190
+ primary: "#268bd2", secondary: "#6c71c4", success: "#859900", error: "#dc322f",
191
+ warning: "#b58900", info: "#2aa198", border: "#073642",
192
+ }),
193
+ light: buildTheme({
194
+ bg: "#fdf6e3", bgSubtle: "#eee8d5", text: "#657b83", textMuted: "#839496", textDim: "#93a1a1",
195
+ primary: "#268bd2", secondary: "#6c71c4", success: "#859900", error: "#dc322f",
196
+ warning: "#b58900", info: "#2aa198", border: "#eee8d5",
197
+ }),
198
+ },
199
+ amoled: {
200
+ name: "AMOLED",
201
+ dark: buildTheme({
202
+ bg: "#000000", bgSubtle: "#0a0a0a", text: "#ffffff", textMuted: "#888888", textDim: "#555555",
203
+ primary: "#00e5ff", secondary: "#e040fb", success: "#00e676", error: "#ff1744",
204
+ warning: "#ffea00", info: "#40c4ff", border: "#222222",
205
+ }),
206
+ light: buildTheme({
207
+ bg: "#ffffff", bgSubtle: "#f5f5f5", text: "#000000", textMuted: "#666666", textDim: "#aaaaaa",
208
+ primary: "#0097a7", secondary: "#7b1fa2", success: "#2e7d32", error: "#c62828",
209
+ warning: "#f9a825", info: "#0277bd", border: "#e0e0e0",
210
+ }),
211
+ },
212
+ github: {
213
+ name: "GitHub",
214
+ dark: buildTheme({
215
+ bg: "#0d1117", bgSubtle: "#161b22", text: "#e6edf3", textMuted: "#8b949e", textDim: "#484f58",
216
+ primary: "#58a6ff", secondary: "#bc8cff", success: "#3fb950", error: "#f85149",
217
+ warning: "#d29922", info: "#58a6ff", border: "#30363d",
218
+ }),
219
+ light: buildTheme({
220
+ bg: "#ffffff", bgSubtle: "#f6f8fa", text: "#1f2328", textMuted: "#656d76", textDim: "#8c959f",
221
+ primary: "#0969da", secondary: "#8250df", success: "#1a7f37", error: "#cf222e",
222
+ warning: "#9a6700", info: "#0969da", border: "#d0d7de",
223
+ }),
224
+ },
225
+ material: {
226
+ name: "Material",
227
+ dark: buildTheme({
228
+ bg: "#263238", bgSubtle: "#37474f", text: "#eeffff", textMuted: "#b0bec5", textDim: "#546e7a",
229
+ primary: "#82aaff", secondary: "#c792ea", success: "#c3e88d", error: "#ff5370",
230
+ warning: "#ffcb6b", info: "#89ddff", border: "#37474f",
231
+ }),
232
+ light: buildTheme({
233
+ bg: "#fafafa", bgSubtle: "#eeeeee", text: "#90a4ae", textMuted: "#546e7a", textDim: "#b0bec5",
234
+ primary: "#6182b8", secondary: "#7c4dff", success: "#91b859", error: "#e53935",
235
+ warning: "#f76d47", info: "#39adb5", border: "#d0d0d0",
236
+ }),
237
+ },
238
+ synthwave: {
239
+ name: "Synthwave '84",
240
+ dark: buildTheme({
241
+ bg: "#262335", bgSubtle: "#34294f", text: "#ffffff", textMuted: "#bbbbbb", textDim: "#6d5da0",
242
+ primary: "#ff7edb", secondary: "#36f9f6", success: "#72f1b8", error: "#fe4450",
243
+ warning: "#fede5d", info: "#36f9f6", border: "#495495",
244
+ }),
245
+ light: buildTheme({
246
+ bg: "#f5f0ff", bgSubtle: "#e8e0f0", text: "#262335", textMuted: "#6d5da0", textDim: "#a090c0",
247
+ primary: "#b03090", secondary: "#108888", success: "#308050", error: "#c03030",
248
+ warning: "#a08000", info: "#207080", border: "#d0c8e0",
249
+ }),
250
+ },
251
+ everforest: {
252
+ name: "Everforest",
253
+ dark: buildTheme({
254
+ bg: "#2d353b", bgSubtle: "#343f44", text: "#d3c6aa", textMuted: "#859289", textDim: "#5c6a72",
255
+ primary: "#a7c080", secondary: "#d699b6", success: "#a7c080", error: "#e67e80",
256
+ warning: "#dbbc7f", info: "#7fbbb3", border: "#475258",
257
+ }),
258
+ light: buildTheme({
259
+ bg: "#fdf6e3", bgSubtle: "#f3efda", text: "#5c6a72", textMuted: "#829181", textDim: "#a6b0a0",
260
+ primary: "#8da101", secondary: "#df69ba", success: "#8da101", error: "#f85552",
261
+ warning: "#dfa000", info: "#35a77c", border: "#e0dcc7",
262
+ }),
263
+ },
264
+ cobalt2: {
265
+ name: "Cobalt2",
266
+ dark: buildTheme({
267
+ bg: "#193549", bgSubtle: "#1f4662", text: "#ffffff", textMuted: "#8db0cc", textDim: "#4d7ea0",
268
+ primary: "#ffc600", secondary: "#ff9d00", success: "#3ad900", error: "#ff628c",
269
+ warning: "#ffc600", info: "#80fcff", border: "#2a5a7a",
270
+ }),
271
+ light: buildTheme({
272
+ bg: "#ffffff", bgSubtle: "#f0f0f0", text: "#193549", textMuted: "#4d7ea0", textDim: "#8db0cc",
273
+ primary: "#b88d00", secondary: "#cc7a00", success: "#2ba600", error: "#cc4e70",
274
+ warning: "#b88d00", info: "#0090a0", border: "#d0d0d0",
275
+ }),
276
+ },
277
+ };
278
+
279
+ /** Get sorted list of theme IDs */
280
+ export function getThemeIds(): string[] {
281
+ return Object.keys(THEMES).sort((a, b) => {
282
+ if (a === "default") return -1;
283
+ if (b === "default") return 1;
284
+ return a.localeCompare(b);
285
+ });
286
+ }
287
+
288
+ /** Get theme by ID and mode */
289
+ export function getThemeColors(themeId: string, mode: "dark" | "light"): Theme {
290
+ const def = THEMES[themeId] || THEMES["default"];
291
+ return mode === "light" ? def.light : def.dark;
292
+ }
293
+
294
+ // ── Terminal detection ────────────────────────────────────
295
+
296
+ function luminance(r: number, g: number, b: number): number {
297
+ const [rs, gs, bs] = [r, g, b].map((c) => {
298
+ const s = c / 255;
299
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
300
+ });
301
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
302
+ }
303
+
304
+ function queryTerminalBackground(): Promise<{ r: number; g: number; b: number } | null> {
305
+ return new Promise((resolve) => {
306
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
307
+ resolve(null);
308
+ return;
309
+ }
310
+ const timeout = setTimeout(() => { cleanup(); resolve(null); }, 500);
311
+ let buf = "";
312
+ const onData = (data: Buffer) => {
313
+ buf += data.toString();
314
+ const match = buf.match(
315
+ /\x1b\]11;rgb:([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})/
316
+ );
317
+ if (match) {
318
+ cleanup();
319
+ const parse = (hex: string) =>
320
+ hex.length <= 2 ? parseInt(hex, 16) : Math.round((parseInt(hex, 16) / 65535) * 255);
321
+ resolve({ r: parse(match[1]), g: parse(match[2]), b: parse(match[3]) });
322
+ }
323
+ };
324
+ const cleanup = () => {
325
+ clearTimeout(timeout);
326
+ process.stdin.removeListener("data", onData);
327
+ if (wasRaw === false) process.stdin.setRawMode(false);
328
+ };
329
+ const wasRaw = process.stdin.isRaw;
330
+ try { process.stdin.setRawMode(true); } catch { resolve(null); return; }
331
+ process.stdin.on("data", onData);
332
+ process.stdout.write("\x1b]11;?\x07");
333
+ });
334
+ }
335
+
336
+ function detectThemeFromEnv(): "dark" | "light" {
337
+ const bg = process.env.COLORFGBG;
338
+ if (bg) {
339
+ const parts = bg.split(";");
340
+ const bgVal = parseInt(parts[parts.length - 1] || "0", 10);
341
+ if (bgVal > 8) return "light";
342
+ }
343
+ return "dark";
344
+ }
345
+
346
+ let _detectedMode: "dark" | "light" | null = null;
347
+
348
+ export async function detectTerminalTheme(): Promise<"dark" | "light"> {
349
+ if (_detectedMode) return _detectedMode;
350
+ const rgb = await queryTerminalBackground();
351
+ if (rgb) {
352
+ const lum = luminance(rgb.r, rgb.g, rgb.b);
353
+ _detectedMode = lum > 0.5 ? "light" : "dark";
354
+ } else {
355
+ _detectedMode = detectThemeFromEnv();
356
+ }
357
+ return _detectedMode;
358
+ }
359
+
360
+ export function setTerminalBackground(color: RGBA): void {
361
+ if (!process.stdout.isTTY) return;
362
+ const hex = rgbToHex(color);
363
+ process.stdout.write(`\x1b]11;${hex}\x07`);
364
+ }
365
+
366
+ export function restoreTerminalBackground(): void {
367
+ if (!process.stdout.isTTY) return;
368
+ process.stdout.write("\x1b]111;\x07");
369
+ }
370
+
371
+ // ── Syntax Style Generation ───────────────────────────────
372
+
373
+ function generateSyntaxStyle(theme: Theme): SyntaxStyle {
374
+ return SyntaxStyle.fromStyles({
375
+ // Default text
376
+ "default": { fg: theme.text },
377
+ // Markdown-specific scopes (used by <markdown> component)
378
+ "markup.heading.1": { fg: theme.primary, bold: true },
379
+ "markup.heading.2": { fg: theme.primary, bold: true },
380
+ "markup.heading.3": { fg: theme.info, bold: true },
381
+ "markup.heading.4": { fg: theme.info, bold: true },
382
+ "markup.heading.5": { fg: theme.info },
383
+ "markup.heading.6": { fg: theme.info },
384
+ "markup.bold": { bold: true },
385
+ "markup.italic": { italic: true },
386
+ "markup.list": { fg: theme.warning },
387
+ "markup.link": { fg: theme.info, underline: true },
388
+ "markup.raw": { fg: theme.warning },
389
+ "markup.quote": { fg: theme.textMuted, italic: true },
390
+ // Code block syntax highlighting
391
+ "keyword": { fg: theme.secondary, bold: true },
392
+ "keyword.control": { fg: theme.secondary, bold: true },
393
+ "keyword.operator": { fg: theme.secondary },
394
+ "string": { fg: theme.success },
395
+ "string.quoted": { fg: theme.success },
396
+ "comment": { fg: theme.textDim, italic: true },
397
+ "comment.line": { fg: theme.textDim, italic: true },
398
+ "comment.block": { fg: theme.textDim, italic: true },
399
+ "constant": { fg: theme.warning },
400
+ "constant.numeric": { fg: theme.warning },
401
+ "number": { fg: theme.warning },
402
+ "variable": { fg: theme.text },
403
+ "variable.parameter": { fg: theme.text },
404
+ "function": { fg: theme.info },
405
+ "entity.name.function": { fg: theme.info },
406
+ "support.function": { fg: theme.info },
407
+ "type": { fg: theme.primary },
408
+ "entity.name.type": { fg: theme.primary },
409
+ "support.type": { fg: theme.primary },
410
+ "operator": { fg: theme.textMuted },
411
+ "punctuation": { fg: theme.textMuted },
412
+ });
413
+ }
414
+
415
+ // ── Theme Context ─────────────────────────────────────────
416
+
417
+ const ThemeContext = createContext<{
418
+ theme: Theme;
419
+ themeId: string;
420
+ mode: "dark" | "light";
421
+ syntaxStyle: SyntaxStyle;
422
+ customBg: string | null;
423
+ setThemeId: (id: string) => void;
424
+ setMode: (m: "dark" | "light") => void;
425
+ setCustomBg: (hex: string | null) => void;
426
+ syncTerminalBg: boolean;
427
+ setSyncTerminalBg: (sync: boolean) => void;
428
+ } | undefined>(undefined);
429
+
430
+ export function ThemeProvider(props: {
431
+ mode?: string;
432
+ themeId?: string;
433
+ syncTerminalBg?: boolean;
434
+ detectedMode?: "dark" | "light";
435
+ children: ReactNode;
436
+ }) {
437
+ const initialMode: "dark" | "light" =
438
+ props.mode === "light" ? "light"
439
+ : props.mode === "auto" ? (props.detectedMode || "dark")
440
+ : "dark";
441
+
442
+ const initialThemeId = props.themeId || "default";
443
+
444
+ const [themeId, setThemeIdState] = useState(initialThemeId);
445
+ const [currentMode, setCurrentMode] = useState<"dark" | "light">(initialMode);
446
+ const [theme, setTheme] = useState<Theme>(getThemeColors(initialThemeId, initialMode));
447
+ const [syncBg, setSyncBg] = useState(props.syncTerminalBg ?? false);
448
+ const [customBg, setCustomBgState] = useState<string | null>(null);
449
+
450
+ // Refs to avoid stale closures — always read current values
451
+ const syncBgRef = useRef(syncBg);
452
+ syncBgRef.current = syncBg;
453
+ const customBgRef = useRef(customBg);
454
+ customBgRef.current = customBg;
455
+ const modeRef = useRef(currentMode);
456
+ modeRef.current = currentMode;
457
+ const themeIdRef = useRef(themeId);
458
+ themeIdRef.current = themeId;
459
+
460
+ /** Apply terminal background to match theme — always call after setTheme */
461
+ const applyTerminalBg = (colors: Theme) => {
462
+ if (!syncBgRef.current) return;
463
+ if (customBgRef.current) {
464
+ setTerminalBackground(RGBA.fromHex(customBgRef.current));
465
+ } else {
466
+ setTerminalBackground(colors.bg);
467
+ }
468
+ };
469
+
470
+ const setThemeId = (id: string) => {
471
+ setThemeIdState(id);
472
+ themeIdRef.current = id;
473
+ const colors = getThemeColors(id, modeRef.current);
474
+ setTheme(colors);
475
+ applyTerminalBg(colors);
476
+ };
477
+
478
+ const setMode = (m: "dark" | "light") => {
479
+ setCurrentMode(m);
480
+ modeRef.current = m;
481
+ const colors = getThemeColors(themeIdRef.current, m);
482
+ setTheme(colors);
483
+ applyTerminalBg(colors);
484
+ };
485
+
486
+ const setCustomBg = (hex: string | null) => {
487
+ setCustomBgState(hex);
488
+ customBgRef.current = hex;
489
+ if (!syncBgRef.current) return;
490
+ if (hex) {
491
+ setTerminalBackground(RGBA.fromHex(hex));
492
+ } else {
493
+ setTerminalBackground(getThemeColors(themeIdRef.current, modeRef.current).bg);
494
+ }
495
+ };
496
+
497
+ const setSyncTerminalBg = (sync: boolean) => {
498
+ setSyncBg(sync);
499
+ syncBgRef.current = sync;
500
+ if (sync) {
501
+ const colors = getThemeColors(themeIdRef.current, modeRef.current);
502
+ applyTerminalBg(colors);
503
+ } else {
504
+ restoreTerminalBackground();
505
+ }
506
+ };
507
+
508
+ // Sync terminal bg on mount — force it immediately
509
+ useEffect(() => {
510
+ if (syncBgRef.current) {
511
+ const colors = getThemeColors(initialThemeId, initialMode);
512
+ applyTerminalBg(colors);
513
+ }
514
+ return () => {
515
+ if (syncBgRef.current) restoreTerminalBackground();
516
+ };
517
+ }, []);
518
+
519
+ const syntaxStyle = useMemo(() => generateSyntaxStyle(theme), [theme]);
520
+
521
+ return (
522
+ <ThemeContext.Provider value={{ theme, themeId, mode: currentMode, syntaxStyle, customBg, setThemeId, setMode, setCustomBg, syncTerminalBg: syncBg, setSyncTerminalBg }}>
523
+ {props.children}
524
+ </ThemeContext.Provider>
525
+ );
526
+ }
527
+
528
+ export function useTheme() {
529
+ const ctx = useContext(ThemeContext);
530
+ if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
531
+ return ctx;
532
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * cdoing-tui — OpenTUI-based terminal interface for cdoing agent
3
+ *
4
+ * This is the advanced TUI (like opencode) using @opentui/react for
5
+ * a rich, interactive terminal experience with:
6
+ * - Split panes (chat + file preview)
7
+ * - Dialog system (model picker, session list, command palette)
8
+ * - Theme support
9
+ * - Keyboard-driven navigation
10
+ *
11
+ * The existing @cdoing/cli uses Ink (React) for a simpler TUI.
12
+ * This package provides the opencode-style experience.
13
+ */
14
+
15
+ import { Command } from "commander";
16
+ import { getDefaultModel, getRegisteredProviders } from "@cdoing/ai";
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name("cdoing-tui")
22
+ .description("OpenTUI-based terminal interface for cdoing agent")
23
+ .version("0.1.0")
24
+ .option("-m, --model <model>", "Model name")
25
+ .option("-p, --provider <provider>", "AI provider", "anthropic")
26
+ .option("--api-key <key>", "API key")
27
+ .option("--base-url <url>", "Base URL for custom providers")
28
+ .option("-d, --dir <directory>", "Working directory", process.cwd())
29
+ .option("--mode <mode>", "Permission mode: ask, auto-edit, auto", "ask")
30
+ .option("-r, --resume <id>", "Resume conversation by ID")
31
+ .option("-c, --continue", "Continue most recent conversation")
32
+ .option("--theme <theme>", "Theme: dark, light, auto", "dark")
33
+ .argument("[prompt]", "Initial prompt")
34
+ .action(async (prompt, opts) => {
35
+ const { startTUI } = await import("./app");
36
+ await startTUI({
37
+ prompt,
38
+ provider: opts.provider,
39
+ model: opts.model || getDefaultModel(opts.provider),
40
+ apiKey: opts.apiKey,
41
+ baseUrl: opts.baseUrl,
42
+ workingDir: opts.dir,
43
+ mode: opts.mode,
44
+ resume: opts.resume,
45
+ continue: opts.continue,
46
+ theme: opts.theme,
47
+ });
48
+ });
49
+
50
+ program.parse();