@cdoing/opentuicli 0.1.6 → 0.1.19
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 +53 -38
- package/dist/index.js.map +4 -4
- package/package.json +5 -4
- package/src/app.tsx +260 -39
- package/src/components/dialog-command.tsx +110 -107
- package/src/components/dialog-help.tsx +48 -124
- package/src/components/dialog-model.tsx +98 -49
- package/src/components/dialog-status.tsx +46 -84
- package/src/components/dialog-theme.tsx +197 -171
- package/src/components/input-area.tsx +74 -12
- package/src/components/message-list.tsx +250 -42
- package/src/components/permission-prompt.tsx +2 -1
- package/src/components/session-browser.tsx +71 -60
- package/src/components/session-footer.tsx +2 -2
- package/src/components/session-header.tsx +1 -1
- package/src/components/setup-wizard.tsx +149 -70
- package/src/components/sidebar.tsx +66 -13
- package/src/components/status-bar.tsx +2 -2
- package/src/context/theme.tsx +109 -1
- package/src/lib/autocomplete.ts +5 -1
- package/src/routes/home.tsx +2 -2
- package/src/routes/session.tsx +141 -18
- package/src/store/settings.ts +107 -0
|
@@ -3,17 +3,53 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Three sections navigable with Tab:
|
|
5
5
|
* 1. Appearance — Dark / Light mode toggle
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
6
|
+
* 2. Color Themes — search + browse built-in themes
|
|
7
|
+
* 3. Custom Background — optional hex override (cleared when a theme is selected)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { TextAttributes } from "@opentui/core";
|
|
11
|
+
import type { SelectOption } from "@opentui/core";
|
|
11
12
|
import { useState, useMemo } from "react";
|
|
12
13
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
13
14
|
import { useTheme, THEMES, getThemeIds } from "../context/theme";
|
|
14
15
|
|
|
15
|
-
type Section = "mode" | "
|
|
16
|
-
const SECTIONS: Section[] = ["mode", "
|
|
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
|
+
}));
|
|
17
53
|
|
|
18
54
|
export function DialogTheme(props: {
|
|
19
55
|
onClose: () => void;
|
|
@@ -28,32 +64,43 @@ export function DialogTheme(props: {
|
|
|
28
64
|
const themeIds = getThemeIds();
|
|
29
65
|
|
|
30
66
|
const [section, setSection] = useState<Section>("mode");
|
|
31
|
-
const [modeSelected, setModeSelected] = useState<number>(mode === "dark" ? 0 : 1);
|
|
32
67
|
const [bgInput, setBgInput] = useState(customBg || "");
|
|
33
68
|
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
69
|
|
|
47
|
-
|
|
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]);
|
|
48
75
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
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]);
|
|
52
85
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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]);
|
|
57
104
|
|
|
58
105
|
const nextSection = () => {
|
|
59
106
|
setSection((s) => {
|
|
@@ -78,46 +125,23 @@ export function DialogTheme(props: {
|
|
|
78
125
|
return;
|
|
79
126
|
}
|
|
80
127
|
|
|
81
|
-
// ──
|
|
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 ──
|
|
128
|
+
// ── Custom BG text input ──
|
|
100
129
|
if (section === "custombg") {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
setCustomBg(null);
|
|
104
|
-
} else if (isValidHex(bgInput)) {
|
|
105
|
-
setCustomBg(bgInput);
|
|
106
|
-
}
|
|
107
|
-
nextSection();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
130
|
+
// Don't capture up/down/return (let <select> handle those)
|
|
131
|
+
if (key.name === "up" || key.name === "down" || key.name === "return") return;
|
|
110
132
|
if (key.name === "backspace") {
|
|
111
133
|
setBgInput((s) => s.slice(0, -1));
|
|
112
134
|
return;
|
|
113
135
|
}
|
|
136
|
+
if (key.ctrl && key.name === "u") {
|
|
137
|
+
setBgInput("");
|
|
138
|
+
setCustomBg(null);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
114
141
|
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
115
142
|
setBgInput((s) => {
|
|
116
143
|
const next = s + key.sequence;
|
|
117
|
-
|
|
118
|
-
if (isValidHex(next)) {
|
|
119
|
-
setCustomBg(next);
|
|
120
|
-
}
|
|
144
|
+
if (isValidHex(next)) setCustomBg(next);
|
|
121
145
|
return next;
|
|
122
146
|
});
|
|
123
147
|
return;
|
|
@@ -125,103 +149,124 @@ export function DialogTheme(props: {
|
|
|
125
149
|
return;
|
|
126
150
|
}
|
|
127
151
|
|
|
128
|
-
// ── Themes
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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);
|
|
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
|
+
}
|
|
157
164
|
return;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
|
|
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
|
-
}
|
|
167
|
+
// Mode section: let <select> handle everything
|
|
173
168
|
});
|
|
174
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
|
+
|
|
175
173
|
return (
|
|
176
174
|
<box
|
|
177
175
|
borderStyle="double"
|
|
178
176
|
borderColor={t.primary}
|
|
177
|
+
backgroundColor={t.bg}
|
|
179
178
|
paddingX={1}
|
|
180
179
|
paddingY={1}
|
|
181
180
|
flexDirection="column"
|
|
182
181
|
position="absolute"
|
|
183
|
-
top=
|
|
184
|
-
left=
|
|
185
|
-
width=
|
|
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}
|
|
186
185
|
>
|
|
187
186
|
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
188
187
|
{" Themes"}
|
|
189
188
|
</text>
|
|
190
189
|
<text fg={t.textDim}>
|
|
191
|
-
{` Mode: ${mode} • ${
|
|
190
|
+
{` Mode: ${mode} • ${filteredThemeOptions.length} themes${customBg ? ` • BG: ${customBg}` : ""}`}
|
|
192
191
|
</text>
|
|
193
192
|
<text>{""}</text>
|
|
194
193
|
|
|
195
|
-
{/* ── Appearance ── */}
|
|
194
|
+
{/* ── 1. Appearance ── */}
|
|
196
195
|
<text
|
|
197
196
|
fg={section === "mode" ? t.primary : t.textDim}
|
|
198
197
|
attributes={section === "mode" ? TextAttributes.BOLD : undefined}
|
|
199
198
|
>
|
|
200
199
|
{" Appearance"}
|
|
201
200
|
</text>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
/>
|
|
217
220
|
<text>{""}</text>
|
|
218
221
|
|
|
219
|
-
{/* ──
|
|
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) ── */}
|
|
220
265
|
<text
|
|
221
266
|
fg={section === "custombg" ? t.primary : t.textDim}
|
|
222
267
|
attributes={section === "custombg" ? TextAttributes.BOLD : undefined}
|
|
223
268
|
>
|
|
224
|
-
{" Custom Background"}
|
|
269
|
+
{" Custom Background Override"}
|
|
225
270
|
</text>
|
|
226
271
|
<box flexDirection="row">
|
|
227
272
|
<text fg={t.textMuted}>{" Hex: "}</text>
|
|
@@ -231,62 +276,43 @@ export function DialogTheme(props: {
|
|
|
231
276
|
<text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
|
|
232
277
|
</>
|
|
233
278
|
) : (
|
|
234
|
-
<text fg={customBg ? t.success : t.textDim}>{customBg || "(theme default)"}</text>
|
|
279
|
+
<text fg={customBg ? t.success : t.textDim}>{customBg || "(using theme default)"}</text>
|
|
235
280
|
)}
|
|
236
281
|
</box>
|
|
237
282
|
{section === "custombg" && (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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>
|
|
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
|
+
</>
|
|
286
312
|
)}
|
|
287
313
|
|
|
288
314
|
<text>{""}</text>
|
|
289
|
-
<text fg={t.textDim}>{" Tab
|
|
315
|
+
<text fg={t.textDim}>{" Tab Section ↑↓ Navigate Enter Select Esc Cancel"}</text>
|
|
290
316
|
</box>
|
|
291
317
|
);
|
|
292
318
|
}
|
|
@@ -12,19 +12,29 @@
|
|
|
12
12
|
* - Escape to close dropdown
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { TextAttributes } from "@opentui/core";
|
|
15
|
+
import { TextAttributes, RGBA } from "@opentui/core";
|
|
16
16
|
import { useState, useRef, useMemo } from "react";
|
|
17
17
|
import { useKeyboard } from "@opentui/react";
|
|
18
18
|
import { execSync } from "child_process";
|
|
19
19
|
import { useTheme } from "../context/theme";
|
|
20
|
-
import { getCompletions, getGhostText
|
|
20
|
+
import { getCompletions, getGhostText } from "../lib/autocomplete";
|
|
21
21
|
import type { ImageAttachment } from "@cdoing/ai";
|
|
22
22
|
|
|
23
|
+
export type AgentMode = "build" | "plan";
|
|
24
|
+
|
|
23
25
|
export interface InputAreaProps {
|
|
24
26
|
onSubmit: (text: string, images?: ImageAttachment[]) => void;
|
|
25
27
|
placeholder?: string;
|
|
26
28
|
disabled?: boolean;
|
|
29
|
+
/** When true, all keyboard input is suppressed (e.g. dialog is open) */
|
|
30
|
+
suppressInput?: boolean;
|
|
27
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;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
function readClipboard(): string {
|
|
@@ -55,7 +65,7 @@ function readClipboardImage(): ImageAttachment | null {
|
|
|
55
65
|
const MAX_VISIBLE = 6;
|
|
56
66
|
|
|
57
67
|
export function InputArea(props: InputAreaProps) {
|
|
58
|
-
const { theme } = useTheme();
|
|
68
|
+
const { theme, customBg } = useTheme();
|
|
59
69
|
const t = theme;
|
|
60
70
|
const [value, setValue] = useState("");
|
|
61
71
|
const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
|
|
@@ -86,8 +96,17 @@ export function InputArea(props: InputAreaProps) {
|
|
|
86
96
|
const showDropdown = dropdownOpen && suggestions.length > 0;
|
|
87
97
|
|
|
88
98
|
useKeyboard((key: any) => {
|
|
99
|
+
// Suppress all input when a dialog overlay is open
|
|
100
|
+
if (props.suppressInput) return;
|
|
101
|
+
|
|
89
102
|
// Allow typing even when disabled (streaming) — submit handler decides what to do
|
|
90
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
|
+
|
|
91
110
|
// ── Dropdown navigation ──
|
|
92
111
|
if (showDropdown) {
|
|
93
112
|
if (key.name === "up") {
|
|
@@ -103,7 +122,6 @@ export function InputArea(props: InputAreaProps) {
|
|
|
103
122
|
if (s) {
|
|
104
123
|
const text = s.text.trim();
|
|
105
124
|
if (text) {
|
|
106
|
-
// Execute the command directly
|
|
107
125
|
props.onSubmit(text, pendingImages.length > 0 ? [...pendingImages] : undefined);
|
|
108
126
|
setValue("");
|
|
109
127
|
setPendingImages([]);
|
|
@@ -117,7 +135,8 @@ export function InputArea(props: InputAreaProps) {
|
|
|
117
135
|
setDropdownOpen(false);
|
|
118
136
|
return;
|
|
119
137
|
}
|
|
120
|
-
|
|
138
|
+
// Arrow right — accept selected autocomplete suggestion
|
|
139
|
+
if (key.name === "right") {
|
|
121
140
|
const s = suggestions[selectedIdx];
|
|
122
141
|
if (s) {
|
|
123
142
|
setValue(s.text + " ");
|
|
@@ -128,8 +147,8 @@ export function InputArea(props: InputAreaProps) {
|
|
|
128
147
|
}
|
|
129
148
|
}
|
|
130
149
|
|
|
131
|
-
// ── Ghost text accept ──
|
|
132
|
-
if (ghost &&
|
|
150
|
+
// ── Ghost text accept (arrow right) ──
|
|
151
|
+
if (ghost && key.name === "right") {
|
|
133
152
|
setValue((v) => v + ghost);
|
|
134
153
|
return;
|
|
135
154
|
}
|
|
@@ -193,8 +212,8 @@ export function InputArea(props: InputAreaProps) {
|
|
|
193
212
|
return;
|
|
194
213
|
}
|
|
195
214
|
|
|
196
|
-
// Escape
|
|
197
|
-
if (key.name === "escape") {
|
|
215
|
+
// Escape — only consume if dropdown is open
|
|
216
|
+
if (key.name === "escape" && dropdownOpen) {
|
|
198
217
|
setDropdownOpen(false);
|
|
199
218
|
return;
|
|
200
219
|
}
|
|
@@ -240,11 +259,13 @@ export function InputArea(props: InputAreaProps) {
|
|
|
240
259
|
const hasAbove = windowStart > 0;
|
|
241
260
|
const hasBelow = windowStart + MAX_VISIBLE < suggestions.length;
|
|
242
261
|
|
|
262
|
+
const bgColor = customBg ? RGBA.fromHex(customBg) : t.bg;
|
|
263
|
+
|
|
243
264
|
return (
|
|
244
|
-
<box flexDirection="column" flexShrink={0}>
|
|
265
|
+
<box flexDirection="column" flexShrink={0} backgroundColor={bgColor}>
|
|
245
266
|
{/* Autocomplete dropdown (above input) */}
|
|
246
267
|
{showDropdown && (
|
|
247
|
-
<box flexDirection="column" paddingX={1}>
|
|
268
|
+
<box flexDirection="column" paddingX={1} backgroundColor={bgColor}>
|
|
248
269
|
{hasAbove && (
|
|
249
270
|
<box><text fg={t.textDim}>{` ▲ ${windowStart} more`}</text></box>
|
|
250
271
|
)}
|
|
@@ -287,6 +308,7 @@ export function InputArea(props: InputAreaProps) {
|
|
|
287
308
|
height={3}
|
|
288
309
|
borderStyle="single"
|
|
289
310
|
borderColor={t.borderFocused}
|
|
311
|
+
backgroundColor={bgColor}
|
|
290
312
|
flexDirection="row"
|
|
291
313
|
alignItems="center"
|
|
292
314
|
paddingX={1}
|
|
@@ -309,10 +331,50 @@ export function InputArea(props: InputAreaProps) {
|
|
|
309
331
|
</>
|
|
310
332
|
) : (
|
|
311
333
|
<text fg={t.textDim}>
|
|
312
|
-
{props.placeholder || "Type a message... (^V paste, / commands, @ context,
|
|
334
|
+
{props.placeholder || "Type a message... (^V paste, / commands, @ context, → accept)"}
|
|
313
335
|
</text>
|
|
314
336
|
)}
|
|
315
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
|
+
)}
|
|
316
378
|
</box>
|
|
317
379
|
);
|
|
318
380
|
}
|