@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.
@@ -3,17 +3,53 @@
3
3
  *
4
4
  * Three sections navigable with Tab:
5
5
  * 1. Appearance — Dark / Light mode toggle
6
- * 2. Custom Backgroundtype a hex color for terminal bg (or clear to use theme default)
7
- * 3. Color Themesbrowse / search built-in themes with live preview
6
+ * 2. Color Themessearch + browse built-in themes
7
+ * 3. Custom Backgroundoptional 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" | "custombg" | "themes";
16
- const SECTIONS: Section[] = ["mode", "custombg", "themes"];
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
- const clampedSelected = Math.min(selected, Math.max(0, filtered.length - 1));
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
- 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);
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
- const modeOptions: Array<{ id: "dark" | "light"; label: string }> = [
54
- { id: "dark", label: "Dark Mode" },
55
- { id: "light", label: "Light Mode" },
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
- // ── 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 ──
128
+ // ── Custom BG text input ──
100
129
  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
- }
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
- // Live preview if valid hex
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 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);
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
- 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
- }
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="10%"
184
- left="20%"
185
- width="60%"
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} • ${filtered.length} themes${customBg ? ` • BG: ${customBg}` : ""}`}
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
- {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
- })}
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
- {/* ── Custom Background ── */}
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
- <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>
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 Switch section Up/Down Navigate Enter Select Esc Cancel"}</text>
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, type Suggestion } from "../lib/autocomplete";
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
- if (key.name === "tab") {
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 && (key.name === "tab" || key.name === "right")) {
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, Tab accept)"}
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
  }