@cdoing/opentuicli 0.1.21 → 0.1.26
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/README.md +22 -13
- package/dist/cdoing-tui-darwin-arm64/bin/cdoing-tui +0 -0
- package/package.json +12 -9
- package/dist/index.js +0 -64
- package/dist/index.js.map +0 -7
- package/esbuild.config.cjs +0 -45
- package/src/app.tsx +0 -787
- package/src/components/dialog-command.tsx +0 -207
- package/src/components/dialog-help.tsx +0 -151
- package/src/components/dialog-model.tsx +0 -142
- package/src/components/dialog-status.tsx +0 -84
- package/src/components/dialog-theme.tsx +0 -318
- package/src/components/input-area.tsx +0 -380
- package/src/components/loading-spinner.tsx +0 -28
- package/src/components/message-list.tsx +0 -546
- package/src/components/permission-prompt.tsx +0 -72
- package/src/components/session-browser.tsx +0 -231
- package/src/components/session-footer.tsx +0 -30
- package/src/components/session-header.tsx +0 -39
- package/src/components/setup-wizard.tsx +0 -542
- package/src/components/sidebar.tsx +0 -183
- package/src/components/status-bar.tsx +0 -76
- package/src/components/toast.tsx +0 -139
- package/src/context/sdk.tsx +0 -40
- package/src/context/theme.tsx +0 -640
- package/src/index.ts +0 -50
- package/src/lib/autocomplete.ts +0 -262
- package/src/lib/context-providers.ts +0 -98
- package/src/lib/history.ts +0 -164
- package/src/lib/terminal-title.ts +0 -15
- package/src/routes/home.tsx +0 -148
- package/src/routes/session.tsx +0 -1309
- package/src/store/settings.ts +0 -107
- package/tsconfig.json +0 -23
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DialogTheme — theme picker dialog (Ctrl+T)
|
|
3
|
-
*
|
|
4
|
-
* Three sections navigable with Tab:
|
|
5
|
-
* 1. Appearance — Dark / Light mode toggle
|
|
6
|
-
* 2. Color Themes — search + browse built-in themes
|
|
7
|
-
* 3. Custom Background — optional hex override (cleared when a theme is selected)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { TextAttributes } from "@opentui/core";
|
|
11
|
-
import type { SelectOption } from "@opentui/core";
|
|
12
|
-
import { useState, useMemo } from "react";
|
|
13
|
-
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
14
|
-
import { useTheme, THEMES, getThemeIds } from "../context/theme";
|
|
15
|
-
|
|
16
|
-
type Section = "mode" | "themes" | "custombg";
|
|
17
|
-
const SECTIONS: Section[] = ["mode", "themes", "custombg"];
|
|
18
|
-
|
|
19
|
-
/** Preset background colors with names for autocomplete */
|
|
20
|
-
const BG_PRESETS: Array<{ hex: string; name: string }> = [
|
|
21
|
-
{ hex: "#000000", name: "Black" },
|
|
22
|
-
{ hex: "#0a0a0a", name: "AMOLED Black" },
|
|
23
|
-
{ hex: "#0d1117", name: "GitHub Dark" },
|
|
24
|
-
{ hex: "#1a1b26", name: "Tokyo Night" },
|
|
25
|
-
{ hex: "#1e1e2e", name: "Catppuccin" },
|
|
26
|
-
{ hex: "#191724", name: "Rosé Pine" },
|
|
27
|
-
{ hex: "#282828", name: "Gruvbox" },
|
|
28
|
-
{ hex: "#282a36", name: "Dracula" },
|
|
29
|
-
{ hex: "#263238", name: "Material" },
|
|
30
|
-
{ hex: "#262335", name: "Synthwave" },
|
|
31
|
-
{ hex: "#272822", name: "Monokai" },
|
|
32
|
-
{ hex: "#2d353b", name: "Everforest" },
|
|
33
|
-
{ hex: "#2e3440", name: "Nord" },
|
|
34
|
-
{ hex: "#002b36", name: "Solarized Dark" },
|
|
35
|
-
{ hex: "#193549", name: "Cobalt2" },
|
|
36
|
-
{ hex: "#032424", name: "Dark Teal" },
|
|
37
|
-
{ hex: "#1a1a2e", name: "Midnight Blue" },
|
|
38
|
-
{ hex: "#0f0f23", name: "Deep Space" },
|
|
39
|
-
{ hex: "#1b2838", name: "Steam" },
|
|
40
|
-
{ hex: "#2b2b2b", name: "VS Code Dark" },
|
|
41
|
-
{ hex: "#fdf6e3", name: "Solarized Light" },
|
|
42
|
-
{ hex: "#ffffff", name: "White" },
|
|
43
|
-
{ hex: "#f5f5f5", name: "Light Gray" },
|
|
44
|
-
{ hex: "#eff1f5", name: "Catppuccin Latte" },
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
/** Convert BG_PRESETS to SelectOption format */
|
|
48
|
-
const BG_SELECT_OPTIONS: SelectOption[] = BG_PRESETS.map((p) => ({
|
|
49
|
-
name: `${p.hex} ${p.name}`,
|
|
50
|
-
description: "",
|
|
51
|
-
value: p.hex,
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
|
-
export function DialogTheme(props: {
|
|
55
|
-
onClose: () => void;
|
|
56
|
-
}) {
|
|
57
|
-
const { theme, themeId, mode, customBg, setThemeId, setMode, setCustomBg } = useTheme();
|
|
58
|
-
const t = theme;
|
|
59
|
-
const dims = useTerminalDimensions();
|
|
60
|
-
|
|
61
|
-
const initialThemeId = themeId;
|
|
62
|
-
const initialMode = mode;
|
|
63
|
-
const initialCustomBg = customBg;
|
|
64
|
-
const themeIds = getThemeIds();
|
|
65
|
-
|
|
66
|
-
const [section, setSection] = useState<Section>("mode");
|
|
67
|
-
const [bgInput, setBgInput] = useState(customBg || "");
|
|
68
|
-
const [query, setQuery] = useState("");
|
|
69
|
-
|
|
70
|
-
// Mode select options
|
|
71
|
-
const modeOptions: SelectOption[] = useMemo(() => [
|
|
72
|
-
{ name: "Dark Mode", description: mode === "dark" ? "active" : "", value: "dark" },
|
|
73
|
-
{ name: "Light Mode", description: mode === "light" ? "active" : "", value: "light" },
|
|
74
|
-
], [mode]);
|
|
75
|
-
|
|
76
|
-
// Filtered BG presets based on input
|
|
77
|
-
const filteredBgOptions: SelectOption[] = useMemo(() => {
|
|
78
|
-
if (!bgInput) return BG_SELECT_OPTIONS;
|
|
79
|
-
const q = bgInput.toLowerCase();
|
|
80
|
-
return BG_SELECT_OPTIONS.filter((o) =>
|
|
81
|
-
o.value.toLowerCase().startsWith(q) ||
|
|
82
|
-
o.name.toLowerCase().includes(q)
|
|
83
|
-
);
|
|
84
|
-
}, [bgInput]);
|
|
85
|
-
|
|
86
|
-
// Filtered theme options
|
|
87
|
-
const filteredThemeOptions: SelectOption[] = useMemo(() => {
|
|
88
|
-
const ids = query
|
|
89
|
-
? themeIds.filter((id) => {
|
|
90
|
-
const def = THEMES[id];
|
|
91
|
-
const q = query.toLowerCase();
|
|
92
|
-
return id.toLowerCase().includes(q) || def.name.toLowerCase().includes(q);
|
|
93
|
-
})
|
|
94
|
-
: themeIds;
|
|
95
|
-
return ids.map((id) => {
|
|
96
|
-
const def = THEMES[id];
|
|
97
|
-
return {
|
|
98
|
-
name: def.name,
|
|
99
|
-
description: id === themeId ? "* current" : id,
|
|
100
|
-
value: id,
|
|
101
|
-
};
|
|
102
|
-
});
|
|
103
|
-
}, [query, themeIds, themeId]);
|
|
104
|
-
|
|
105
|
-
const nextSection = () => {
|
|
106
|
-
setSection((s) => {
|
|
107
|
-
const idx = SECTIONS.indexOf(s);
|
|
108
|
-
return SECTIONS[(idx + 1) % SECTIONS.length];
|
|
109
|
-
});
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const isValidHex = (s: string): boolean => /^#[0-9a-fA-F]{6}$/.test(s);
|
|
113
|
-
|
|
114
|
-
useKeyboard((key: any) => {
|
|
115
|
-
if (key.name === "escape") {
|
|
116
|
-
setThemeId(initialThemeId);
|
|
117
|
-
setMode(initialMode);
|
|
118
|
-
setCustomBg(initialCustomBg);
|
|
119
|
-
props.onClose();
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (key.name === "tab") {
|
|
124
|
-
nextSection();
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── Custom BG text input ──
|
|
129
|
-
if (section === "custombg") {
|
|
130
|
-
// Don't capture up/down/return (let <select> handle those)
|
|
131
|
-
if (key.name === "up" || key.name === "down" || key.name === "return") return;
|
|
132
|
-
if (key.name === "backspace") {
|
|
133
|
-
setBgInput((s) => s.slice(0, -1));
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
if (key.ctrl && key.name === "u") {
|
|
137
|
-
setBgInput("");
|
|
138
|
-
setCustomBg(null);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
142
|
-
setBgInput((s) => {
|
|
143
|
-
const next = s + key.sequence;
|
|
144
|
-
if (isValidHex(next)) setCustomBg(next);
|
|
145
|
-
return next;
|
|
146
|
-
});
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Themes search input ──
|
|
153
|
-
if (section === "themes") {
|
|
154
|
-
// Don't capture up/down/return (let <select> handle those)
|
|
155
|
-
if (key.name === "up" || key.name === "down" || key.name === "return") return;
|
|
156
|
-
if (key.name === "backspace") {
|
|
157
|
-
setQuery((q) => q.slice(0, -1));
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
161
|
-
setQuery((q) => q + key.sequence);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Mode section: let <select> handle everything
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const dialogWidth = Math.min(60, (dims.width || 80) - 4);
|
|
171
|
-
const selectHeight = Math.max(3, Math.floor((dims.height || 24) * 0.2));
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<box
|
|
175
|
-
borderStyle="double"
|
|
176
|
-
borderColor={t.primary}
|
|
177
|
-
backgroundColor={t.bg}
|
|
178
|
-
paddingX={1}
|
|
179
|
-
paddingY={1}
|
|
180
|
-
flexDirection="column"
|
|
181
|
-
position="absolute"
|
|
182
|
-
top={Math.max(1, Math.floor((dims.height || 24) * 0.1))}
|
|
183
|
-
left={Math.max(1, Math.floor(((dims.width || 80) - dialogWidth) / 2))}
|
|
184
|
-
width={dialogWidth}
|
|
185
|
-
>
|
|
186
|
-
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
187
|
-
{" Themes"}
|
|
188
|
-
</text>
|
|
189
|
-
<text fg={t.textDim}>
|
|
190
|
-
{` Mode: ${mode} • ${filteredThemeOptions.length} themes${customBg ? ` • BG: ${customBg}` : ""}`}
|
|
191
|
-
</text>
|
|
192
|
-
<text>{""}</text>
|
|
193
|
-
|
|
194
|
-
{/* ── 1. Appearance ── */}
|
|
195
|
-
<text
|
|
196
|
-
fg={section === "mode" ? t.primary : t.textDim}
|
|
197
|
-
attributes={section === "mode" ? TextAttributes.BOLD : undefined}
|
|
198
|
-
>
|
|
199
|
-
{" Appearance"}
|
|
200
|
-
</text>
|
|
201
|
-
<select
|
|
202
|
-
options={modeOptions}
|
|
203
|
-
focused={section === "mode"}
|
|
204
|
-
selectedIndex={mode === "dark" ? 0 : 1}
|
|
205
|
-
height={2}
|
|
206
|
-
showDescription={false}
|
|
207
|
-
backgroundColor={customBg || undefined}
|
|
208
|
-
focusedBackgroundColor={customBg || undefined}
|
|
209
|
-
textColor={t.textMuted}
|
|
210
|
-
focusedTextColor={t.text}
|
|
211
|
-
selectedBackgroundColor={t.primary}
|
|
212
|
-
selectedTextColor={t.bg}
|
|
213
|
-
onSelect={(_index: number, option: SelectOption | null) => {
|
|
214
|
-
if (option?.value) {
|
|
215
|
-
setMode(option.value as "dark" | "light");
|
|
216
|
-
nextSection();
|
|
217
|
-
}
|
|
218
|
-
}}
|
|
219
|
-
/>
|
|
220
|
-
<text>{""}</text>
|
|
221
|
-
|
|
222
|
-
{/* ── 2. Color Themes ── */}
|
|
223
|
-
<text
|
|
224
|
-
fg={section === "themes" ? t.primary : t.textDim}
|
|
225
|
-
attributes={section === "themes" ? TextAttributes.BOLD : undefined}
|
|
226
|
-
>
|
|
227
|
-
{" Color Themes"}
|
|
228
|
-
</text>
|
|
229
|
-
{section === "themes" && (
|
|
230
|
-
<box flexDirection="row">
|
|
231
|
-
<text fg={t.textMuted}>{" Search: "}</text>
|
|
232
|
-
<text fg={t.text}>{query || ""}</text>
|
|
233
|
-
<text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
|
|
234
|
-
</box>
|
|
235
|
-
)}
|
|
236
|
-
<select
|
|
237
|
-
options={filteredThemeOptions}
|
|
238
|
-
focused={section === "themes"}
|
|
239
|
-
height={selectHeight}
|
|
240
|
-
showDescription={true}
|
|
241
|
-
backgroundColor={customBg || undefined}
|
|
242
|
-
focusedBackgroundColor={customBg || undefined}
|
|
243
|
-
textColor={t.text}
|
|
244
|
-
focusedTextColor={t.text}
|
|
245
|
-
selectedBackgroundColor={t.primary}
|
|
246
|
-
selectedTextColor={t.bg}
|
|
247
|
-
descriptionColor={t.textDim}
|
|
248
|
-
selectedDescriptionColor={t.bg}
|
|
249
|
-
showScrollIndicator={filteredThemeOptions.length > selectHeight}
|
|
250
|
-
onChange={(_index: number, option: SelectOption | null) => {
|
|
251
|
-
if (option?.value) {
|
|
252
|
-
setThemeId(option.value);
|
|
253
|
-
// Clear custom bg so the theme's own bg applies
|
|
254
|
-
setCustomBg(null);
|
|
255
|
-
setBgInput("");
|
|
256
|
-
}
|
|
257
|
-
}}
|
|
258
|
-
onSelect={(_index: number, _option: SelectOption | null) => {
|
|
259
|
-
props.onClose();
|
|
260
|
-
}}
|
|
261
|
-
/>
|
|
262
|
-
<text>{""}</text>
|
|
263
|
-
|
|
264
|
-
{/* ── 3. Custom Background (override) ── */}
|
|
265
|
-
<text
|
|
266
|
-
fg={section === "custombg" ? t.primary : t.textDim}
|
|
267
|
-
attributes={section === "custombg" ? TextAttributes.BOLD : undefined}
|
|
268
|
-
>
|
|
269
|
-
{" Custom Background Override"}
|
|
270
|
-
</text>
|
|
271
|
-
<box flexDirection="row">
|
|
272
|
-
<text fg={t.textMuted}>{" Hex: "}</text>
|
|
273
|
-
{section === "custombg" ? (
|
|
274
|
-
<>
|
|
275
|
-
<text fg={isValidHex(bgInput) ? t.success : t.text}>{bgInput || ""}</text>
|
|
276
|
-
<text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
|
|
277
|
-
</>
|
|
278
|
-
) : (
|
|
279
|
-
<text fg={customBg ? t.success : t.textDim}>{customBg || "(using theme default)"}</text>
|
|
280
|
-
)}
|
|
281
|
-
</box>
|
|
282
|
-
{section === "custombg" && (
|
|
283
|
-
<>
|
|
284
|
-
<text fg={t.textDim}>{" Type #hex, Ctrl+U clear, ↑↓ presets, Enter apply"}</text>
|
|
285
|
-
<select
|
|
286
|
-
options={filteredBgOptions}
|
|
287
|
-
focused={section === "custombg"}
|
|
288
|
-
height={Math.min(6, filteredBgOptions.length)}
|
|
289
|
-
showDescription={false}
|
|
290
|
-
backgroundColor={customBg || undefined}
|
|
291
|
-
focusedBackgroundColor={customBg || undefined}
|
|
292
|
-
textColor={t.textMuted}
|
|
293
|
-
focusedTextColor={t.text}
|
|
294
|
-
selectedBackgroundColor={t.primary}
|
|
295
|
-
selectedTextColor={t.bg}
|
|
296
|
-
showScrollIndicator={filteredBgOptions.length > 6}
|
|
297
|
-
onChange={(_index: number, option: SelectOption | null) => {
|
|
298
|
-
if (option?.value) {
|
|
299
|
-
setBgInput(option.value);
|
|
300
|
-
setCustomBg(option.value);
|
|
301
|
-
}
|
|
302
|
-
}}
|
|
303
|
-
onSelect={(_index: number, option: SelectOption | null) => {
|
|
304
|
-
if (option?.value) {
|
|
305
|
-
setBgInput(option.value);
|
|
306
|
-
setCustomBg(option.value);
|
|
307
|
-
}
|
|
308
|
-
props.onClose();
|
|
309
|
-
}}
|
|
310
|
-
/>
|
|
311
|
-
</>
|
|
312
|
-
)}
|
|
313
|
-
|
|
314
|
-
<text>{""}</text>
|
|
315
|
-
<text fg={t.textDim}>{" Tab Section ↑↓ Navigate Enter Select Esc Cancel"}</text>
|
|
316
|
-
</box>
|
|
317
|
-
);
|
|
318
|
-
}
|
|
@@ -1,380 +0,0 @@
|
|
|
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, RGBA } 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 } from "../lib/autocomplete";
|
|
21
|
-
import type { ImageAttachment } from "@cdoing/ai";
|
|
22
|
-
|
|
23
|
-
export type AgentMode = "build" | "plan";
|
|
24
|
-
|
|
25
|
-
export interface InputAreaProps {
|
|
26
|
-
onSubmit: (text: string, images?: ImageAttachment[]) => void;
|
|
27
|
-
placeholder?: string;
|
|
28
|
-
disabled?: boolean;
|
|
29
|
-
/** When true, all keyboard input is suppressed (e.g. dialog is open) */
|
|
30
|
-
suppressInput?: boolean;
|
|
31
|
-
workingDir: string;
|
|
32
|
-
/** Current agent mode (build/plan). If provided, shows mode tabs. */
|
|
33
|
-
mode?: AgentMode;
|
|
34
|
-
/** Callback when mode changes via Tab key */
|
|
35
|
-
onModeChange?: (mode: AgentMode) => void;
|
|
36
|
-
/** Model name to display in the tab bar */
|
|
37
|
-
modelLabel?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function readClipboard(): string {
|
|
41
|
-
try {
|
|
42
|
-
if (process.platform === "darwin") return execSync("pbpaste", { encoding: "utf-8" });
|
|
43
|
-
try { return execSync("xclip -selection clipboard -o", { encoding: "utf-8" }); }
|
|
44
|
-
catch { return execSync("xsel --clipboard --output", { encoding: "utf-8" }); }
|
|
45
|
-
} catch { return ""; }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function readClipboardImage(): ImageAttachment | null {
|
|
49
|
-
if (process.platform !== "darwin") return null;
|
|
50
|
-
try {
|
|
51
|
-
const hasImage = execSync(
|
|
52
|
-
`osascript -e 'clipboard info' 2>/dev/null | grep -q "TIFF\\|PNG\\|JPEG" && echo "yes" || echo "no"`,
|
|
53
|
-
{ encoding: "utf-8", timeout: 1000 },
|
|
54
|
-
).trim();
|
|
55
|
-
if (hasImage !== "yes") return null;
|
|
56
|
-
const base64 = execSync(
|
|
57
|
-
`osascript -e 'set theImage to the clipboard as «class PNGf»' -e 'return theImage' 2>/dev/null | base64`,
|
|
58
|
-
{ encoding: "utf-8", timeout: 3000, maxBuffer: 20 * 1024 * 1024 },
|
|
59
|
-
).trim();
|
|
60
|
-
if (base64 && base64.length > 100) return { data: base64, mimeType: "image/png" };
|
|
61
|
-
} catch {}
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const MAX_VISIBLE = 6;
|
|
66
|
-
|
|
67
|
-
export function InputArea(props: InputAreaProps) {
|
|
68
|
-
const { theme, customBg } = useTheme();
|
|
69
|
-
const t = theme;
|
|
70
|
-
const [value, setValue] = useState("");
|
|
71
|
-
const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
|
|
72
|
-
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
73
|
-
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
74
|
-
const valueRef = useRef(value);
|
|
75
|
-
valueRef.current = value;
|
|
76
|
-
const imageCountRef = useRef(0);
|
|
77
|
-
|
|
78
|
-
// Compute suggestions based on current input, with a leading "(none)" option for path completions
|
|
79
|
-
const suggestions = useMemo(() => {
|
|
80
|
-
if (!value) return [];
|
|
81
|
-
const completions = getCompletions(value, props.workingDir);
|
|
82
|
-
// Add a "none" option at the top for file/subcommand completions so user can submit as-is
|
|
83
|
-
if (completions.length > 0 && completions[0].type === "file") {
|
|
84
|
-
return [{ text: value, description: "submit as typed", type: "file" as const }, ...completions];
|
|
85
|
-
}
|
|
86
|
-
return completions;
|
|
87
|
-
}, [value, props.workingDir]);
|
|
88
|
-
|
|
89
|
-
// Compute ghost text
|
|
90
|
-
const ghost = useMemo(() => {
|
|
91
|
-
if (dropdownOpen && suggestions.length > 0) return "";
|
|
92
|
-
return getGhostText(value, props.workingDir);
|
|
93
|
-
}, [value, props.workingDir, dropdownOpen, suggestions.length]);
|
|
94
|
-
|
|
95
|
-
// Auto-open dropdown when suggestions exist
|
|
96
|
-
const showDropdown = dropdownOpen && suggestions.length > 0;
|
|
97
|
-
|
|
98
|
-
useKeyboard((key: any) => {
|
|
99
|
-
// Suppress all input when a dialog overlay is open
|
|
100
|
-
if (props.suppressInput) return;
|
|
101
|
-
|
|
102
|
-
// Allow typing even when disabled (streaming) — submit handler decides what to do
|
|
103
|
-
|
|
104
|
-
// ── Tab — always switch mode (build ↔ plan) ──
|
|
105
|
-
if (key.name === "tab" && props.onModeChange && props.mode) {
|
|
106
|
-
props.onModeChange(props.mode === "build" ? "plan" : "build");
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── Dropdown navigation ──
|
|
111
|
-
if (showDropdown) {
|
|
112
|
-
if (key.name === "up") {
|
|
113
|
-
setSelectedIdx((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
if (key.name === "down") {
|
|
117
|
-
setSelectedIdx((i) => (i >= suggestions.length - 1 ? 0 : i + 1));
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (key.name === "return") {
|
|
121
|
-
const s = suggestions[selectedIdx];
|
|
122
|
-
if (s) {
|
|
123
|
-
const text = s.text.trim();
|
|
124
|
-
if (text) {
|
|
125
|
-
props.onSubmit(text, pendingImages.length > 0 ? [...pendingImages] : undefined);
|
|
126
|
-
setValue("");
|
|
127
|
-
setPendingImages([]);
|
|
128
|
-
}
|
|
129
|
-
setDropdownOpen(false);
|
|
130
|
-
setSelectedIdx(0);
|
|
131
|
-
}
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (key.name === "escape") {
|
|
135
|
-
setDropdownOpen(false);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
// Arrow right — accept selected autocomplete suggestion
|
|
139
|
-
if (key.name === "right") {
|
|
140
|
-
const s = suggestions[selectedIdx];
|
|
141
|
-
if (s) {
|
|
142
|
-
setValue(s.text + " ");
|
|
143
|
-
setDropdownOpen(false);
|
|
144
|
-
setSelectedIdx(0);
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Ghost text accept (arrow right) ──
|
|
151
|
-
if (ghost && key.name === "right") {
|
|
152
|
-
setValue((v) => v + ghost);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Ctrl+V — paste image or text
|
|
157
|
-
if (key.ctrl && key.name === "v") {
|
|
158
|
-
const img = readClipboardImage();
|
|
159
|
-
if (img) {
|
|
160
|
-
imageCountRef.current += 1;
|
|
161
|
-
setPendingImages((prev) => [...prev, img]);
|
|
162
|
-
setValue((v) => v + `[Image #${imageCountRef.current}] `);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const clip = readClipboard().trim();
|
|
166
|
-
if (clip) {
|
|
167
|
-
const firstLine = clip.split("\n")[0] || "";
|
|
168
|
-
setValue((v) => v + firstLine);
|
|
169
|
-
}
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Ctrl+U — clear line
|
|
174
|
-
if (key.ctrl && key.name === "u") {
|
|
175
|
-
setValue("");
|
|
176
|
-
setPendingImages([]);
|
|
177
|
-
setDropdownOpen(false);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Ctrl+W — delete last word
|
|
182
|
-
if (key.ctrl && key.name === "w") {
|
|
183
|
-
setValue((v) => v.replace(/\S+\s*$/, ""));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Enter — submit
|
|
188
|
-
if (key.name === "return" && !key.shift) {
|
|
189
|
-
const text = valueRef.current.trim();
|
|
190
|
-
if (text || pendingImages.length > 0) {
|
|
191
|
-
props.onSubmit(text || "Describe this image.", pendingImages.length > 0 ? [...pendingImages] : undefined);
|
|
192
|
-
setValue("");
|
|
193
|
-
setPendingImages([]);
|
|
194
|
-
setDropdownOpen(false);
|
|
195
|
-
}
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Backspace
|
|
200
|
-
if (key.name === "backspace") {
|
|
201
|
-
setValue((v) => {
|
|
202
|
-
const next = v.slice(0, -1);
|
|
203
|
-
// Re-evaluate dropdown
|
|
204
|
-
if (next.startsWith("/") || next.includes("@") || getCompletions(next, props.workingDir).length > 0) {
|
|
205
|
-
setDropdownOpen(true);
|
|
206
|
-
setSelectedIdx(0);
|
|
207
|
-
} else {
|
|
208
|
-
setDropdownOpen(false);
|
|
209
|
-
}
|
|
210
|
-
return next;
|
|
211
|
-
});
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Escape — only consume if dropdown is open
|
|
216
|
-
if (key.name === "escape" && dropdownOpen) {
|
|
217
|
-
setDropdownOpen(false);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Space
|
|
222
|
-
if (key.name === "space") {
|
|
223
|
-
setValue((v) => {
|
|
224
|
-
const next = v + " ";
|
|
225
|
-
// Close dropdown on space for slash commands, but check for path completions
|
|
226
|
-
if (v.startsWith("/")) {
|
|
227
|
-
setDropdownOpen(false);
|
|
228
|
-
} else if (getCompletions(next, props.workingDir).length > 0) {
|
|
229
|
-
setDropdownOpen(true);
|
|
230
|
-
setSelectedIdx(0);
|
|
231
|
-
}
|
|
232
|
-
return next;
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Regular character
|
|
238
|
-
if (key.name && key.name.length === 1 && !key.ctrl && !key.meta) {
|
|
239
|
-
setValue((v) => {
|
|
240
|
-
const next = v + key.name;
|
|
241
|
-
// Auto-open dropdown for /, @, and path completions
|
|
242
|
-
if (next.startsWith("/") || next.includes("@")) {
|
|
243
|
-
setDropdownOpen(true);
|
|
244
|
-
setSelectedIdx(0);
|
|
245
|
-
} else if (getCompletions(next, props.workingDir).length > 0) {
|
|
246
|
-
setDropdownOpen(true);
|
|
247
|
-
setSelectedIdx(0);
|
|
248
|
-
}
|
|
249
|
-
return next;
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const isSlashCommand = value.startsWith("/");
|
|
255
|
-
|
|
256
|
-
// Window the suggestions for display
|
|
257
|
-
const windowStart = Math.max(0, Math.min(selectedIdx - Math.floor(MAX_VISIBLE / 2), suggestions.length - MAX_VISIBLE));
|
|
258
|
-
const visibleSuggestions = suggestions.slice(windowStart, windowStart + MAX_VISIBLE);
|
|
259
|
-
const hasAbove = windowStart > 0;
|
|
260
|
-
const hasBelow = windowStart + MAX_VISIBLE < suggestions.length;
|
|
261
|
-
|
|
262
|
-
const bgColor = customBg ? RGBA.fromHex(customBg) : t.bg;
|
|
263
|
-
|
|
264
|
-
return (
|
|
265
|
-
<box flexDirection="column" flexShrink={0} backgroundColor={bgColor}>
|
|
266
|
-
{/* Autocomplete dropdown (above input) */}
|
|
267
|
-
{showDropdown && (
|
|
268
|
-
<box flexDirection="column" paddingX={1} backgroundColor={bgColor}>
|
|
269
|
-
{hasAbove && (
|
|
270
|
-
<box><text fg={t.textDim}>{` ▲ ${windowStart} more`}</text></box>
|
|
271
|
-
)}
|
|
272
|
-
{visibleSuggestions.map((s, i) => {
|
|
273
|
-
const realIdx = windowStart + i;
|
|
274
|
-
const isSelected = realIdx === selectedIdx;
|
|
275
|
-
const color = s.type === "command" ? t.warning
|
|
276
|
-
: s.type === "mention" ? t.info
|
|
277
|
-
: s.type === "file" ? t.success
|
|
278
|
-
: t.textMuted;
|
|
279
|
-
return (
|
|
280
|
-
<box key={s.text} flexDirection="row">
|
|
281
|
-
<text fg={isSelected ? t.primary : color} attributes={isSelected ? TextAttributes.BOLD : undefined}>
|
|
282
|
-
{isSelected ? " ❯ " : " "}
|
|
283
|
-
</text>
|
|
284
|
-
<text fg={isSelected ? t.text : color}>{s.text}</text>
|
|
285
|
-
{s.description ? (
|
|
286
|
-
<text fg={t.textDim}>{` ${s.description}`}</text>
|
|
287
|
-
) : null}
|
|
288
|
-
</box>
|
|
289
|
-
);
|
|
290
|
-
})}
|
|
291
|
-
{hasBelow && (
|
|
292
|
-
<box><text fg={t.textDim}>{` ▼ ${suggestions.length - windowStart - MAX_VISIBLE} more`}</text></box>
|
|
293
|
-
)}
|
|
294
|
-
</box>
|
|
295
|
-
)}
|
|
296
|
-
|
|
297
|
-
{/* Pending images indicator */}
|
|
298
|
-
{pendingImages.length > 0 && (
|
|
299
|
-
<box height={1} paddingX={1}>
|
|
300
|
-
<text fg={t.primary}>
|
|
301
|
-
{` 🖼 ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached`}
|
|
302
|
-
</text>
|
|
303
|
-
</box>
|
|
304
|
-
)}
|
|
305
|
-
|
|
306
|
-
{/* Input box */}
|
|
307
|
-
<box
|
|
308
|
-
height={3}
|
|
309
|
-
borderStyle="single"
|
|
310
|
-
borderColor={t.borderFocused}
|
|
311
|
-
backgroundColor={bgColor}
|
|
312
|
-
flexDirection="row"
|
|
313
|
-
alignItems="center"
|
|
314
|
-
paddingX={1}
|
|
315
|
-
>
|
|
316
|
-
<text
|
|
317
|
-
fg={isSlashCommand ? t.warning : t.primary}
|
|
318
|
-
attributes={TextAttributes.BOLD}
|
|
319
|
-
>
|
|
320
|
-
{isSlashCommand ? " / " : " > "}
|
|
321
|
-
</text>
|
|
322
|
-
|
|
323
|
-
{value ? (
|
|
324
|
-
<>
|
|
325
|
-
<text fg={t.text}>{value}</text>
|
|
326
|
-
{ghost ? (
|
|
327
|
-
<text fg={t.textDim}>{ghost}</text>
|
|
328
|
-
) : (
|
|
329
|
-
<text fg={t.primary}>{"▊"}</text>
|
|
330
|
-
)}
|
|
331
|
-
</>
|
|
332
|
-
) : (
|
|
333
|
-
<text fg={t.textDim}>
|
|
334
|
-
{props.placeholder || "Type a message... (^V paste, / commands, @ context, → accept)"}
|
|
335
|
-
</text>
|
|
336
|
-
)}
|
|
337
|
-
</box>
|
|
338
|
-
|
|
339
|
-
{/* Mode tab bar (Build / Plan) + model label + shortcuts */}
|
|
340
|
-
{props.mode && (
|
|
341
|
-
<box height={1} flexDirection="row" backgroundColor={bgColor} paddingX={1}>
|
|
342
|
-
{/* Build tab */}
|
|
343
|
-
<box backgroundColor={props.mode === "build" ? t.primary : undefined}>
|
|
344
|
-
<text
|
|
345
|
-
fg={props.mode === "build" ? t.bg : t.textMuted}
|
|
346
|
-
attributes={props.mode === "build" ? TextAttributes.BOLD : undefined}
|
|
347
|
-
>
|
|
348
|
-
{" Build "}
|
|
349
|
-
</text>
|
|
350
|
-
</box>
|
|
351
|
-
<text fg={t.textDim}>{" "}</text>
|
|
352
|
-
{/* Plan tab */}
|
|
353
|
-
<box backgroundColor={props.mode === "plan" ? t.secondary : undefined}>
|
|
354
|
-
<text
|
|
355
|
-
fg={props.mode === "plan" ? t.bg : t.textMuted}
|
|
356
|
-
attributes={props.mode === "plan" ? TextAttributes.BOLD : undefined}
|
|
357
|
-
>
|
|
358
|
-
{" Plan "}
|
|
359
|
-
</text>
|
|
360
|
-
</box>
|
|
361
|
-
|
|
362
|
-
{/* Model label */}
|
|
363
|
-
{props.modelLabel && (
|
|
364
|
-
<>
|
|
365
|
-
<text fg={t.textDim}>{" "}</text>
|
|
366
|
-
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
|
|
367
|
-
{props.modelLabel}
|
|
368
|
-
</text>
|
|
369
|
-
</>
|
|
370
|
-
)}
|
|
371
|
-
|
|
372
|
-
{/* Right-aligned shortcut hints */}
|
|
373
|
-
<box flexGrow={1} />
|
|
374
|
-
<text fg={t.textDim}>{"tab mode "}</text>
|
|
375
|
-
<text fg={t.textDim}>{"ctrl+p commands"}</text>
|
|
376
|
-
</box>
|
|
377
|
-
)}
|
|
378
|
-
</box>
|
|
379
|
-
);
|
|
380
|
-
}
|