@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.
@@ -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
- }