@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,292 @@
1
+ /**
2
+ * DialogTheme — theme picker dialog (Ctrl+T)
3
+ *
4
+ * Three sections navigable with Tab:
5
+ * 1. Appearance — Dark / Light mode toggle
6
+ * 2. Custom Background — type a hex color for terminal bg (or clear to use theme default)
7
+ * 3. Color Themes — browse / search built-in themes with live preview
8
+ */
9
+
10
+ import { TextAttributes } from "@opentui/core";
11
+ import { useState, useMemo } from "react";
12
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
13
+ import { useTheme, THEMES, getThemeIds } from "../context/theme";
14
+
15
+ type Section = "mode" | "custombg" | "themes";
16
+ const SECTIONS: Section[] = ["mode", "custombg", "themes"];
17
+
18
+ export function DialogTheme(props: {
19
+ onClose: () => void;
20
+ }) {
21
+ const { theme, themeId, mode, customBg, setThemeId, setMode, setCustomBg } = useTheme();
22
+ const t = theme;
23
+ const dims = useTerminalDimensions();
24
+
25
+ const initialThemeId = themeId;
26
+ const initialMode = mode;
27
+ const initialCustomBg = customBg;
28
+ const themeIds = getThemeIds();
29
+
30
+ const [section, setSection] = useState<Section>("mode");
31
+ const [modeSelected, setModeSelected] = useState<number>(mode === "dark" ? 0 : 1);
32
+ const [bgInput, setBgInput] = useState(customBg || "");
33
+ const [query, setQuery] = useState("");
34
+ const [selected, setSelected] = useState(
35
+ Math.max(0, themeIds.indexOf(themeId))
36
+ );
37
+
38
+ const filtered = useMemo(() => {
39
+ if (!query) return themeIds;
40
+ const q = query.toLowerCase();
41
+ return themeIds.filter((id) => {
42
+ const def = THEMES[id];
43
+ return id.toLowerCase().includes(q) || def.name.toLowerCase().includes(q);
44
+ });
45
+ }, [query, themeIds]);
46
+
47
+ const clampedSelected = Math.min(selected, Math.max(0, filtered.length - 1));
48
+
49
+ const maxVisible = Math.max(5, Math.min(filtered.length, (dims.height || 24) - 18));
50
+ const scrollOffset = Math.max(0, clampedSelected - maxVisible + 3);
51
+ const visibleItems = filtered.slice(scrollOffset, scrollOffset + maxVisible);
52
+
53
+ const modeOptions: Array<{ id: "dark" | "light"; label: string }> = [
54
+ { id: "dark", label: "Dark Mode" },
55
+ { id: "light", label: "Light Mode" },
56
+ ];
57
+
58
+ const nextSection = () => {
59
+ setSection((s) => {
60
+ const idx = SECTIONS.indexOf(s);
61
+ return SECTIONS[(idx + 1) % SECTIONS.length];
62
+ });
63
+ };
64
+
65
+ const isValidHex = (s: string): boolean => /^#[0-9a-fA-F]{6}$/.test(s);
66
+
67
+ useKeyboard((key: any) => {
68
+ if (key.name === "escape") {
69
+ setThemeId(initialThemeId);
70
+ setMode(initialMode);
71
+ setCustomBg(initialCustomBg);
72
+ props.onClose();
73
+ return;
74
+ }
75
+
76
+ if (key.name === "tab") {
77
+ nextSection();
78
+ return;
79
+ }
80
+
81
+ // ── Mode section ──
82
+ if (section === "mode") {
83
+ if (key.name === "up" || key.name === "k") {
84
+ setModeSelected((s) => Math.max(0, s - 1));
85
+ return;
86
+ }
87
+ if (key.name === "down" || key.name === "j") {
88
+ setModeSelected((s) => Math.min(modeOptions.length - 1, s + 1));
89
+ return;
90
+ }
91
+ if (key.name === "return") {
92
+ setMode(modeOptions[modeSelected].id);
93
+ nextSection();
94
+ return;
95
+ }
96
+ return;
97
+ }
98
+
99
+ // ── Custom BG section ──
100
+ if (section === "custombg") {
101
+ if (key.name === "return") {
102
+ if (bgInput === "" || bgInput === "none") {
103
+ setCustomBg(null);
104
+ } else if (isValidHex(bgInput)) {
105
+ setCustomBg(bgInput);
106
+ }
107
+ nextSection();
108
+ return;
109
+ }
110
+ if (key.name === "backspace") {
111
+ setBgInput((s) => s.slice(0, -1));
112
+ return;
113
+ }
114
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
115
+ setBgInput((s) => {
116
+ const next = s + key.sequence;
117
+ // Live preview if valid hex
118
+ if (isValidHex(next)) {
119
+ setCustomBg(next);
120
+ }
121
+ return next;
122
+ });
123
+ return;
124
+ }
125
+ return;
126
+ }
127
+
128
+ // ── Themes section ──
129
+ if (key.name === "return") {
130
+ props.onClose();
131
+ return;
132
+ }
133
+
134
+ if (key.name === "up" || (key.name === "k" && !query)) {
135
+ setSelected((s) => {
136
+ const next = Math.max(0, s - 1);
137
+ const id = filtered[next];
138
+ if (id) setThemeId(id);
139
+ return next;
140
+ });
141
+ return;
142
+ }
143
+
144
+ if (key.name === "down" || (key.name === "j" && !query)) {
145
+ setSelected((s) => {
146
+ const next = Math.min(filtered.length - 1, s + 1);
147
+ const id = filtered[next];
148
+ if (id) setThemeId(id);
149
+ return next;
150
+ });
151
+ return;
152
+ }
153
+
154
+ if (key.name === "backspace") {
155
+ setQuery((q) => q.slice(0, -1));
156
+ setSelected(0);
157
+ return;
158
+ }
159
+
160
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
161
+ setQuery((q) => {
162
+ const newQ = q + key.sequence;
163
+ const q2 = newQ.toLowerCase();
164
+ const first = themeIds.find((id) => {
165
+ const def = THEMES[id];
166
+ return id.toLowerCase().includes(q2) || def.name.toLowerCase().includes(q2);
167
+ });
168
+ if (first) setThemeId(first);
169
+ return newQ;
170
+ });
171
+ setSelected(0);
172
+ }
173
+ });
174
+
175
+ return (
176
+ <box
177
+ borderStyle="double"
178
+ borderColor={t.primary}
179
+ paddingX={1}
180
+ paddingY={1}
181
+ flexDirection="column"
182
+ position="absolute"
183
+ top="10%"
184
+ left="20%"
185
+ width="60%"
186
+ >
187
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>
188
+ {" Themes"}
189
+ </text>
190
+ <text fg={t.textDim}>
191
+ {` Mode: ${mode} • ${filtered.length} themes${customBg ? ` • BG: ${customBg}` : ""}`}
192
+ </text>
193
+ <text>{""}</text>
194
+
195
+ {/* ── Appearance ── */}
196
+ <text
197
+ fg={section === "mode" ? t.primary : t.textDim}
198
+ attributes={section === "mode" ? TextAttributes.BOLD : undefined}
199
+ >
200
+ {" Appearance"}
201
+ </text>
202
+ {modeOptions.map((opt, i) => {
203
+ const isSel = section === "mode" && i === modeSelected;
204
+ const isActive = opt.id === mode;
205
+ return (
206
+ <box key={opt.id} flexDirection="row">
207
+ <text
208
+ fg={isSel ? t.primary : t.text}
209
+ attributes={isSel ? TextAttributes.BOLD : undefined}
210
+ >
211
+ {` ${isSel ? ">" : " "} ${opt.label}`}
212
+ </text>
213
+ {isActive && <text fg={t.success}>{" *"}</text>}
214
+ </box>
215
+ );
216
+ })}
217
+ <text>{""}</text>
218
+
219
+ {/* ── Custom Background ── */}
220
+ <text
221
+ fg={section === "custombg" ? t.primary : t.textDim}
222
+ attributes={section === "custombg" ? TextAttributes.BOLD : undefined}
223
+ >
224
+ {" Custom Background"}
225
+ </text>
226
+ <box flexDirection="row">
227
+ <text fg={t.textMuted}>{" Hex: "}</text>
228
+ {section === "custombg" ? (
229
+ <>
230
+ <text fg={isValidHex(bgInput) ? t.success : t.text}>{bgInput || ""}</text>
231
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
232
+ </>
233
+ ) : (
234
+ <text fg={customBg ? t.success : t.textDim}>{customBg || "(theme default)"}</text>
235
+ )}
236
+ </box>
237
+ {section === "custombg" && (
238
+ <text fg={t.textDim}>{" Type #rrggbb hex, empty to clear. Enter to apply."}</text>
239
+ )}
240
+ <text>{""}</text>
241
+
242
+ {/* ── Color Themes ── */}
243
+ <text
244
+ fg={section === "themes" ? t.primary : t.textDim}
245
+ attributes={section === "themes" ? TextAttributes.BOLD : undefined}
246
+ >
247
+ {" Color Themes"}
248
+ </text>
249
+
250
+ {section === "themes" && (
251
+ <box flexDirection="row">
252
+ <text fg={t.textMuted}>{" > "}</text>
253
+ <text fg={t.text}>{query || ""}</text>
254
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
255
+ </box>
256
+ )}
257
+
258
+ {visibleItems.length > 0 ? (
259
+ visibleItems.map((id) => {
260
+ const def = THEMES[id];
261
+ const idx = filtered.indexOf(id);
262
+ const isSel = section === "themes" && idx === clampedSelected;
263
+ const isCurrent = id === themeId;
264
+
265
+ return (
266
+ <box key={id} flexDirection="row">
267
+ <text
268
+ fg={isSel ? t.primary : t.text}
269
+ attributes={isSel ? TextAttributes.BOLD : undefined}
270
+ >
271
+ {` ${isSel ? ">" : " "} ${def.name}`}
272
+ </text>
273
+ <text fg={t.textDim}>{` ${id}`}</text>
274
+ {isCurrent && <text fg={t.success}>{" *"}</text>}
275
+ </box>
276
+ );
277
+ })
278
+ ) : (
279
+ <text fg={t.textDim}>{" No matching themes"}</text>
280
+ )}
281
+
282
+ {filtered.length > maxVisible && (
283
+ <text fg={t.textDim}>
284
+ {` ... ${filtered.length - maxVisible} more`}
285
+ </text>
286
+ )}
287
+
288
+ <text>{""}</text>
289
+ <text fg={t.textDim}>{" Tab Switch section Up/Down Navigate Enter Select Esc Cancel"}</text>
290
+ </box>
291
+ );
292
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * InputArea — rich input with autocomplete, ghost text, image paste
3
+ *
4
+ * Features:
5
+ * - Slash command autocomplete dropdown (/ prefix)
6
+ * - @mention autocomplete dropdown (@ prefix)
7
+ * - Tool subcommand suggestions (npm, git, etc.)
8
+ * - Ghost text inline completion (Tab/→ to accept)
9
+ * - Ctrl+V paste text or images (macOS clipboard)
10
+ * - Ctrl+U clear line, Ctrl+W delete word
11
+ * - Up/Down navigate suggestions, Enter to select
12
+ * - Escape to close dropdown
13
+ */
14
+
15
+ import { TextAttributes } from "@opentui/core";
16
+ import { useState, useRef, useMemo } from "react";
17
+ import { useKeyboard } from "@opentui/react";
18
+ import { execSync } from "child_process";
19
+ import { useTheme } from "../context/theme";
20
+ import { getCompletions, getGhostText, type Suggestion } from "../lib/autocomplete";
21
+ import type { ImageAttachment } from "@cdoing/ai";
22
+
23
+ export interface InputAreaProps {
24
+ onSubmit: (text: string, images?: ImageAttachment[]) => void;
25
+ placeholder?: string;
26
+ disabled?: boolean;
27
+ workingDir: string;
28
+ }
29
+
30
+ function readClipboard(): string {
31
+ try {
32
+ if (process.platform === "darwin") return execSync("pbpaste", { encoding: "utf-8" });
33
+ try { return execSync("xclip -selection clipboard -o", { encoding: "utf-8" }); }
34
+ catch { return execSync("xsel --clipboard --output", { encoding: "utf-8" }); }
35
+ } catch { return ""; }
36
+ }
37
+
38
+ function readClipboardImage(): ImageAttachment | null {
39
+ if (process.platform !== "darwin") return null;
40
+ try {
41
+ const hasImage = execSync(
42
+ `osascript -e 'clipboard info' 2>/dev/null | grep -q "TIFF\\|PNG\\|JPEG" && echo "yes" || echo "no"`,
43
+ { encoding: "utf-8", timeout: 1000 },
44
+ ).trim();
45
+ if (hasImage !== "yes") return null;
46
+ const base64 = execSync(
47
+ `osascript -e 'set theImage to the clipboard as «class PNGf»' -e 'return theImage' 2>/dev/null | base64`,
48
+ { encoding: "utf-8", timeout: 3000, maxBuffer: 20 * 1024 * 1024 },
49
+ ).trim();
50
+ if (base64 && base64.length > 100) return { data: base64, mimeType: "image/png" };
51
+ } catch {}
52
+ return null;
53
+ }
54
+
55
+ const MAX_VISIBLE = 6;
56
+
57
+ export function InputArea(props: InputAreaProps) {
58
+ const { theme } = useTheme();
59
+ const t = theme;
60
+ const [value, setValue] = useState("");
61
+ const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
62
+ const [selectedIdx, setSelectedIdx] = useState(0);
63
+ const [dropdownOpen, setDropdownOpen] = useState(false);
64
+ const valueRef = useRef(value);
65
+ valueRef.current = value;
66
+ const imageCountRef = useRef(0);
67
+
68
+ // Compute suggestions based on current input, with a leading "(none)" option for path completions
69
+ const suggestions = useMemo(() => {
70
+ if (!value) return [];
71
+ const completions = getCompletions(value, props.workingDir);
72
+ // Add a "none" option at the top for file/subcommand completions so user can submit as-is
73
+ if (completions.length > 0 && completions[0].type === "file") {
74
+ return [{ text: value, description: "submit as typed", type: "file" as const }, ...completions];
75
+ }
76
+ return completions;
77
+ }, [value, props.workingDir]);
78
+
79
+ // Compute ghost text
80
+ const ghost = useMemo(() => {
81
+ if (dropdownOpen && suggestions.length > 0) return "";
82
+ return getGhostText(value, props.workingDir);
83
+ }, [value, props.workingDir, dropdownOpen, suggestions.length]);
84
+
85
+ // Auto-open dropdown when suggestions exist
86
+ const showDropdown = dropdownOpen && suggestions.length > 0;
87
+
88
+ useKeyboard((key: any) => {
89
+ // Allow typing even when disabled (streaming) — submit handler decides what to do
90
+
91
+ // ── Dropdown navigation ──
92
+ if (showDropdown) {
93
+ if (key.name === "up") {
94
+ setSelectedIdx((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
95
+ return;
96
+ }
97
+ if (key.name === "down") {
98
+ setSelectedIdx((i) => (i >= suggestions.length - 1 ? 0 : i + 1));
99
+ return;
100
+ }
101
+ if (key.name === "return") {
102
+ const s = suggestions[selectedIdx];
103
+ if (s) {
104
+ const text = s.text.trim();
105
+ if (text) {
106
+ // Execute the command directly
107
+ props.onSubmit(text, pendingImages.length > 0 ? [...pendingImages] : undefined);
108
+ setValue("");
109
+ setPendingImages([]);
110
+ }
111
+ setDropdownOpen(false);
112
+ setSelectedIdx(0);
113
+ }
114
+ return;
115
+ }
116
+ if (key.name === "escape") {
117
+ setDropdownOpen(false);
118
+ return;
119
+ }
120
+ if (key.name === "tab") {
121
+ const s = suggestions[selectedIdx];
122
+ if (s) {
123
+ setValue(s.text + " ");
124
+ setDropdownOpen(false);
125
+ setSelectedIdx(0);
126
+ }
127
+ return;
128
+ }
129
+ }
130
+
131
+ // ── Ghost text accept ──
132
+ if (ghost && (key.name === "tab" || key.name === "right")) {
133
+ setValue((v) => v + ghost);
134
+ return;
135
+ }
136
+
137
+ // Ctrl+V — paste image or text
138
+ if (key.ctrl && key.name === "v") {
139
+ const img = readClipboardImage();
140
+ if (img) {
141
+ imageCountRef.current += 1;
142
+ setPendingImages((prev) => [...prev, img]);
143
+ setValue((v) => v + `[Image #${imageCountRef.current}] `);
144
+ return;
145
+ }
146
+ const clip = readClipboard().trim();
147
+ if (clip) {
148
+ const firstLine = clip.split("\n")[0] || "";
149
+ setValue((v) => v + firstLine);
150
+ }
151
+ return;
152
+ }
153
+
154
+ // Ctrl+U — clear line
155
+ if (key.ctrl && key.name === "u") {
156
+ setValue("");
157
+ setPendingImages([]);
158
+ setDropdownOpen(false);
159
+ return;
160
+ }
161
+
162
+ // Ctrl+W — delete last word
163
+ if (key.ctrl && key.name === "w") {
164
+ setValue((v) => v.replace(/\S+\s*$/, ""));
165
+ return;
166
+ }
167
+
168
+ // Enter — submit
169
+ if (key.name === "return" && !key.shift) {
170
+ const text = valueRef.current.trim();
171
+ if (text || pendingImages.length > 0) {
172
+ props.onSubmit(text || "Describe this image.", pendingImages.length > 0 ? [...pendingImages] : undefined);
173
+ setValue("");
174
+ setPendingImages([]);
175
+ setDropdownOpen(false);
176
+ }
177
+ return;
178
+ }
179
+
180
+ // Backspace
181
+ if (key.name === "backspace") {
182
+ setValue((v) => {
183
+ const next = v.slice(0, -1);
184
+ // Re-evaluate dropdown
185
+ if (next.startsWith("/") || next.includes("@") || getCompletions(next, props.workingDir).length > 0) {
186
+ setDropdownOpen(true);
187
+ setSelectedIdx(0);
188
+ } else {
189
+ setDropdownOpen(false);
190
+ }
191
+ return next;
192
+ });
193
+ return;
194
+ }
195
+
196
+ // Escape
197
+ if (key.name === "escape") {
198
+ setDropdownOpen(false);
199
+ return;
200
+ }
201
+
202
+ // Space
203
+ if (key.name === "space") {
204
+ setValue((v) => {
205
+ const next = v + " ";
206
+ // Close dropdown on space for slash commands, but check for path completions
207
+ if (v.startsWith("/")) {
208
+ setDropdownOpen(false);
209
+ } else if (getCompletions(next, props.workingDir).length > 0) {
210
+ setDropdownOpen(true);
211
+ setSelectedIdx(0);
212
+ }
213
+ return next;
214
+ });
215
+ return;
216
+ }
217
+
218
+ // Regular character
219
+ if (key.name && key.name.length === 1 && !key.ctrl && !key.meta) {
220
+ setValue((v) => {
221
+ const next = v + key.name;
222
+ // Auto-open dropdown for /, @, and path completions
223
+ if (next.startsWith("/") || next.includes("@")) {
224
+ setDropdownOpen(true);
225
+ setSelectedIdx(0);
226
+ } else if (getCompletions(next, props.workingDir).length > 0) {
227
+ setDropdownOpen(true);
228
+ setSelectedIdx(0);
229
+ }
230
+ return next;
231
+ });
232
+ }
233
+ });
234
+
235
+ const isSlashCommand = value.startsWith("/");
236
+
237
+ // Window the suggestions for display
238
+ const windowStart = Math.max(0, Math.min(selectedIdx - Math.floor(MAX_VISIBLE / 2), suggestions.length - MAX_VISIBLE));
239
+ const visibleSuggestions = suggestions.slice(windowStart, windowStart + MAX_VISIBLE);
240
+ const hasAbove = windowStart > 0;
241
+ const hasBelow = windowStart + MAX_VISIBLE < suggestions.length;
242
+
243
+ return (
244
+ <box flexDirection="column" flexShrink={0}>
245
+ {/* Autocomplete dropdown (above input) */}
246
+ {showDropdown && (
247
+ <box flexDirection="column" paddingX={1}>
248
+ {hasAbove && (
249
+ <box><text fg={t.textDim}>{` ▲ ${windowStart} more`}</text></box>
250
+ )}
251
+ {visibleSuggestions.map((s, i) => {
252
+ const realIdx = windowStart + i;
253
+ const isSelected = realIdx === selectedIdx;
254
+ const color = s.type === "command" ? t.warning
255
+ : s.type === "mention" ? t.info
256
+ : s.type === "file" ? t.success
257
+ : t.textMuted;
258
+ return (
259
+ <box key={s.text} flexDirection="row">
260
+ <text fg={isSelected ? t.primary : color} attributes={isSelected ? TextAttributes.BOLD : undefined}>
261
+ {isSelected ? " ❯ " : " "}
262
+ </text>
263
+ <text fg={isSelected ? t.text : color}>{s.text}</text>
264
+ {s.description ? (
265
+ <text fg={t.textDim}>{` ${s.description}`}</text>
266
+ ) : null}
267
+ </box>
268
+ );
269
+ })}
270
+ {hasBelow && (
271
+ <box><text fg={t.textDim}>{` ▼ ${suggestions.length - windowStart - MAX_VISIBLE} more`}</text></box>
272
+ )}
273
+ </box>
274
+ )}
275
+
276
+ {/* Pending images indicator */}
277
+ {pendingImages.length > 0 && (
278
+ <box height={1} paddingX={1}>
279
+ <text fg={t.primary}>
280
+ {` 🖼 ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached`}
281
+ </text>
282
+ </box>
283
+ )}
284
+
285
+ {/* Input box */}
286
+ <box
287
+ height={3}
288
+ borderStyle="single"
289
+ borderColor={t.borderFocused}
290
+ flexDirection="row"
291
+ alignItems="center"
292
+ paddingX={1}
293
+ >
294
+ <text
295
+ fg={isSlashCommand ? t.warning : t.primary}
296
+ attributes={TextAttributes.BOLD}
297
+ >
298
+ {isSlashCommand ? " / " : " > "}
299
+ </text>
300
+
301
+ {value ? (
302
+ <>
303
+ <text fg={t.text}>{value}</text>
304
+ {ghost ? (
305
+ <text fg={t.textDim}>{ghost}</text>
306
+ ) : (
307
+ <text fg={t.primary}>{"▊"}</text>
308
+ )}
309
+ </>
310
+ ) : (
311
+ <text fg={t.textDim}>
312
+ {props.placeholder || "Type a message... (^V paste, / commands, @ context, Tab accept)"}
313
+ </text>
314
+ )}
315
+ </box>
316
+ </box>
317
+ );
318
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * LoadingSpinner — animated thinking/tool indicator
3
+ */
4
+
5
+ import { useState, useEffect } from "react";
6
+ import { useTheme } from "../context/theme";
7
+
8
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+
10
+ export function LoadingSpinner(props: { label?: string }) {
11
+ const { theme } = useTheme();
12
+ const t = theme;
13
+ const [frame, setFrame] = useState(0);
14
+
15
+ useEffect(() => {
16
+ const interval = setInterval(() => {
17
+ setFrame((f) => (f + 1) % FRAMES.length);
18
+ }, 80);
19
+ return () => clearInterval(interval);
20
+ }, []);
21
+
22
+ return (
23
+ <box paddingX={1} height={1} flexDirection="row">
24
+ <text fg={t.primary}>{FRAMES[frame]}</text>
25
+ <text fg={t.textMuted}>{` ${props.label || "Thinking..."}`}</text>
26
+ </box>
27
+ );
28
+ }