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