@hicoders/devkit 1.0.0

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.
@@ -0,0 +1,83 @@
1
+ // Help — a full-screen keybinding cheat-sheet. Render it as the whole screen
2
+ // (instead of the normal content) while help is requested; any key closes it.
3
+ //
4
+ // Layout mirrors ListSelect exactly — a plain flex-column box whose children are
5
+ // each their own row <box>. Bordered/padded containers that directly stack many
6
+ // children miscalculate child heights on some terminals and overlap; the plain
7
+ // column + per-row boxes used here render reliably. Keep all text ASCII (see
8
+ // CLAUDE.md): ambiguous-width glyphs corrupt line rendering.
9
+
10
+ import type { ReactNode } from "react";
11
+ import { useKeyboard } from "@opentui/react";
12
+ import { useTheme } from "../theme-context";
13
+
14
+ export interface Binding {
15
+ keys: string;
16
+ desc: string;
17
+ }
18
+
19
+ export function Help({
20
+ title,
21
+ bindings,
22
+ active = true,
23
+ onClose,
24
+ }: {
25
+ title: string;
26
+ bindings: Binding[];
27
+ active?: boolean;
28
+ onClose: () => void;
29
+ }) {
30
+ const theme = useTheme();
31
+ useKeyboard((key) => {
32
+ if (!active) return;
33
+ onClose(); // any key dismisses
34
+ void key;
35
+ });
36
+
37
+ // A binding with empty `keys` is a section header (its `desc` is the heading).
38
+ const pad = Math.max(...bindings.map((b) => b.keys.length));
39
+
40
+ const Row = ({ children }: { children: ReactNode }) => (
41
+ <box style={{ flexDirection: "row" }}>{children}</box>
42
+ );
43
+ const Blank = () => (
44
+ <box style={{ height: 1, flexDirection: "row" }}>
45
+ <text> </text>
46
+ </box>
47
+ );
48
+
49
+ const lines: ReactNode[] = [];
50
+ bindings.forEach((b, i) => {
51
+ if (b.keys === "") {
52
+ if (lines.length) lines.push(<Blank key={`gap-${i}`} />);
53
+ lines.push(
54
+ <Row key={i}>
55
+ <text fg={theme.accentDim}>{b.desc}</text>
56
+ </Row>,
57
+ );
58
+ } else {
59
+ lines.push(
60
+ <Row key={i}>
61
+ <text fg={theme.yellow}>{b.keys.padEnd(pad)}</text>
62
+ <text fg={theme.dim}>{` ${b.desc}`}</text>
63
+ </Row>,
64
+ );
65
+ }
66
+ });
67
+
68
+ return (
69
+ <box style={{ padding: 1, flexDirection: "column" }}>
70
+ <box style={{ flexDirection: "column" }}>
71
+ <Row>
72
+ <text fg={theme.accent}>{title}</text>
73
+ </Row>
74
+ <Blank />
75
+ {lines}
76
+ <Blank />
77
+ <Row>
78
+ <text fg={theme.dim}>press any key to close</text>
79
+ </Row>
80
+ </box>
81
+ </box>
82
+ );
83
+ }
@@ -0,0 +1,436 @@
1
+ // ListSelect — the shared, keyboard-driven list used by every devkit screen.
2
+ //
3
+ // Built on <box>/<text> + useKeyboard (rather than the built-in <select>) so we
4
+ // control row columns, multi-select marking, and colors. Supports:
5
+ // - arrows or j/k to move, with wrap-around
6
+ // - "/" to filter (type to narrow, Backspace to edit, Esc to clear)
7
+ // - Space / Tab to toggle a mark (when `multiSelect`)
8
+ // - "v" visual range: anchor at the cursor, sweep with j/k, "v" again to leave
9
+ // (commit) the swept rows; Esc cancels the sweep (when `multiSelect`)
10
+ // - Enter to submit (effective selection if any, otherwise the highlighted row)
11
+ // - q to cancel; Esc peels back one step at a time — clear filter, then cancel
12
+ // a visual sweep, then clear the selection — and only then a second Esc exits
13
+ // (the first shows a "press again" hint)
14
+ // - optional section headers via `sectionOf` (purely decorative — navigation is
15
+ // unaffected, headers are derived from the already-sorted items)
16
+ // Pass `active={false}` to make it ignore keys (e.g. while a confirm/help shows).
17
+
18
+ import { useEffect, useMemo, useRef, useState } from "react";
19
+ import type { ReactNode } from "react";
20
+ import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/react";
21
+ import { useTheme, useThemeControls } from "../theme-context";
22
+
23
+ // Drop control chars (incl. CR/LF/Esc) from typed/pasted filter text.
24
+ const printable = (s: string) => s.replace(/[\x00-\x1f]/g, "");
25
+
26
+ export interface ListSelectProps<T> {
27
+ items: T[];
28
+ getKey: (item: T) => string;
29
+ renderRow: (item: T, state: { selected: boolean; marked: boolean }) => ReactNode;
30
+ /** Text matched against the "/" filter query. Defaults to getKey. */
31
+ filterText?: (item: T) => string;
32
+ /** Whether a row may be marked/submitted. Defaults to always true. */
33
+ isSelectable?: (item: T) => boolean;
34
+ multiSelect?: boolean;
35
+ /** Keys to pre-mark when the list first mounts (multiSelect only). */
36
+ initialMarked?: string[];
37
+ /**
38
+ * When true, a lone Esc cancels immediately (go back) in a single press — no
39
+ * peel of a sweep/selection and no "press again" confirmation. Use on
40
+ * transient picker screens where Esc means "go back one step".
41
+ */
42
+ immediateCancel?: boolean;
43
+ active?: boolean;
44
+ onSubmit: (items: T[]) => void;
45
+ onCancel?: () => void;
46
+ /** Called for un-handled keys when not filtering (e.g. "r", "a"). */
47
+ onExtraKey?: (name: string, current: T | null) => void;
48
+ /** Section id for an item; when set, a dim header is drawn at group boundaries. */
49
+ sectionOf?: (item: T) => string;
50
+ /** Header text for a section (defaults to the id + count). */
51
+ sectionLabel?: (sectionId: string, count: number) => string;
52
+ /** Notified when the user is mid-interaction (filtering or a visual sweep). */
53
+ onInteractingChange?: (busy: boolean) => void;
54
+ /** Enables [ / ] to move the highlighted row up / down. Called with the row and
55
+ * direction; the list advances the cursor in lockstep so the highlight stays
56
+ * on the moved row. Confined to a section when `sectionOf` is set. */
57
+ onReorder?: (item: T, dir: -1 | 1) => void;
58
+ emptyText?: string;
59
+ /** Max rows to show at once; defaults to terminal height minus chrome. */
60
+ maxVisible?: number;
61
+ }
62
+
63
+ export function ListSelect<T>(props: ListSelectProps<T>) {
64
+ const {
65
+ items,
66
+ getKey,
67
+ renderRow,
68
+ filterText = getKey,
69
+ isSelectable = () => true,
70
+ multiSelect = false,
71
+ initialMarked,
72
+ immediateCancel = false,
73
+ onReorder,
74
+ active = true,
75
+ onSubmit,
76
+ onCancel,
77
+ onExtraKey,
78
+ sectionOf,
79
+ sectionLabel,
80
+ onInteractingChange,
81
+ emptyText = "(nothing to show)",
82
+ } = props;
83
+
84
+ const theme = useTheme();
85
+ const { cycle: cycleTheme } = useThemeControls();
86
+
87
+ const [cursor, setCursor] = useState(0);
88
+ const [marked, setMarked] = useState<Set<string>>(() => new Set(initialMarked ?? []));
89
+ const [anchor, setAnchor] = useState<number | null>(null);
90
+ const [filtering, setFiltering] = useState(false);
91
+ const [query, setQuery] = useState("");
92
+ const [escArmed, setEscArmed] = useState(false); // first Esc arms, second exits
93
+
94
+ // Auto-disarm the "press Esc again" prompt after a short window.
95
+ const escTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
96
+ const armEsc = () => {
97
+ setEscArmed(true);
98
+ if (escTimer.current) clearTimeout(escTimer.current);
99
+ escTimer.current = setTimeout(() => setEscArmed(false), 2000);
100
+ };
101
+ const disarmEsc = () => {
102
+ if (escTimer.current) clearTimeout(escTimer.current);
103
+ escTimer.current = null;
104
+ setEscArmed(false);
105
+ };
106
+ useEffect(() => () => disarmEsc(), []); // clear timer on unmount
107
+
108
+ const filtered = useMemo(() => {
109
+ const q = query.trim().toLowerCase();
110
+ if (!q) return items;
111
+ return items.filter((it) => filterText(it).toLowerCase().includes(q));
112
+ }, [items, query, filterText]);
113
+
114
+ const cur = Math.min(cursor, Math.max(0, filtered.length - 1));
115
+
116
+ // Keys swept by the active visual range (anchor..cursor, inclusive).
117
+ const rangeKeys = (a: number | null, c: number, list: T[]): string[] => {
118
+ if (a === null) return [];
119
+ const lo = Math.min(a, c);
120
+ const hi = Math.max(a, c);
121
+ const keys: string[] = [];
122
+ for (let i = lo; i <= hi; i++) {
123
+ const it = list[i];
124
+ if (it && isSelectable(it)) keys.push(getKey(it));
125
+ }
126
+ return keys;
127
+ };
128
+
129
+ // marks ∪ visual range — what the indicator shows and what Enter submits.
130
+ const effective = useMemo(() => {
131
+ const set = new Set(marked);
132
+ for (const k of rangeKeys(anchor, cur, filtered)) set.add(k);
133
+ return set;
134
+ }, [marked, anchor, cur, filtered]);
135
+
136
+ // Mirror derived state so the keyboard handler always reads fresh values.
137
+ const ref = useRef({ filtered, cur, filtering, anchor, marked, escArmed });
138
+ ref.current = { filtered, cur, filtering, anchor, marked, escArmed };
139
+
140
+ // Let the parent pause things (e.g. auto-refresh) while the user is filtering
141
+ // or mid visual-sweep, so the list doesn't shift under them.
142
+ const interacting = filtering || anchor !== null;
143
+ useEffect(() => {
144
+ onInteractingChange?.(interacting);
145
+ }, [interacting]); // eslint-disable-line react-hooks/exhaustive-deps
146
+
147
+ // Drop marks whose row no longer exists (after a kill, refresh, or filter).
148
+ useEffect(() => {
149
+ setMarked((prev) => {
150
+ if (prev.size === 0) return prev;
151
+ const keys = new Set(items.map(getKey));
152
+ const next = new Set<string>();
153
+ for (const k of prev) if (keys.has(k)) next.add(k);
154
+ return next.size === prev.size ? prev : next;
155
+ });
156
+ }, [items]); // eslint-disable-line react-hooks/exhaustive-deps
157
+
158
+ const move = (delta: number) => {
159
+ const n = ref.current.filtered.length;
160
+ if (!n) return;
161
+ let i = (ref.current.cur + delta) % n;
162
+ if (i < 0) i += n;
163
+ setCursor(i);
164
+ };
165
+
166
+ // Mouse: scroll moves the cursor (clamped, no wrap); a click highlights a row
167
+ // and a double-click activates it.
168
+ const scrollBy = (delta: number) => {
169
+ const n = ref.current.filtered.length;
170
+ if (!n) return;
171
+ setCursor((c) => Math.max(0, Math.min(n - 1, c + delta)));
172
+ };
173
+ const lastClick = useRef<{ idx: number; at: number }>({ idx: -1, at: 0 });
174
+ const clickRow = (idx: number) => {
175
+ setCursor(idx);
176
+ const now = Date.now();
177
+ const dbl = lastClick.current.idx === idx && now - lastClick.current.at < 400;
178
+ lastClick.current = dbl ? { idx: -1, at: 0 } : { idx, at: now };
179
+ if (dbl) {
180
+ const item = ref.current.filtered[idx];
181
+ if (item && isSelectable(item)) onSubmit([item]);
182
+ }
183
+ };
184
+
185
+ const toggleMark = () => {
186
+ const item = ref.current.filtered[ref.current.cur];
187
+ if (!item || !isSelectable(item)) return;
188
+ const k = getKey(item);
189
+ setMarked((prev) => {
190
+ const next = new Set(prev);
191
+ next.has(k) ? next.delete(k) : next.add(k);
192
+ return next;
193
+ });
194
+ };
195
+
196
+ // "v": start a sweep, or leave it (committing the swept rows to marks).
197
+ const toggleVisual = () => {
198
+ const { anchor: a, cur: c, filtered: list } = ref.current;
199
+ if (a === null) {
200
+ setAnchor(c);
201
+ return;
202
+ }
203
+ const sweep = rangeKeys(a, c, list);
204
+ setMarked((prev) => new Set([...prev, ...sweep]));
205
+ setAnchor(null);
206
+ };
207
+
208
+ // "[" / "]": ask the parent to move the highlighted row up/down, and advance
209
+ // the cursor in lockstep so the highlight stays on it. Won't cross a section
210
+ // boundary (when sectionOf is set). The parent persists; we just move the view.
211
+ const reorder = (dir: -1 | 1) => {
212
+ if (!onReorder) return;
213
+ const { filtered: list, cur: c } = ref.current;
214
+ const j = c + dir;
215
+ const a = list[c];
216
+ const b = list[j];
217
+ if (!a || !b) return;
218
+ if (sectionOf && sectionOf(a) !== sectionOf(b)) return;
219
+ onReorder(a, dir);
220
+ setCursor(j);
221
+ };
222
+
223
+ const submit = () => {
224
+ const { filtered: list, cur: c, anchor: a, marked: m } = ref.current;
225
+ if (multiSelect) {
226
+ const eff = new Set(m);
227
+ for (const k of rangeKeys(a, c, list)) eff.add(k);
228
+ if (eff.size) {
229
+ const picked = items.filter((it) => eff.has(getKey(it)) && isSelectable(it));
230
+ if (picked.length) onSubmit(picked);
231
+ return;
232
+ }
233
+ }
234
+ const item = list[c];
235
+ if (item && isSelectable(item)) onSubmit([item]);
236
+ };
237
+
238
+ // Pasting (bracketed paste) goes into the filter query while filtering.
239
+ usePaste((event) => {
240
+ if (!active || !ref.current.filtering) return;
241
+ const text = printable(new TextDecoder().decode(event.bytes));
242
+ if (text) {
243
+ setQuery((q) => q + text);
244
+ setCursor(0);
245
+ }
246
+ });
247
+
248
+ useKeyboard((key) => {
249
+ if (!active) return;
250
+
251
+ // Any key other than Esc cancels a pending "press Esc again to exit".
252
+ if (key.name !== "escape" && ref.current.escArmed) disarmEsc();
253
+
254
+ if (ref.current.filtering) {
255
+ if (key.name === "escape") {
256
+ setFiltering(false);
257
+ setQuery("");
258
+ setCursor(0);
259
+ } else if (key.name === "return") {
260
+ submit();
261
+ } else if (key.name === "backspace") {
262
+ setQuery((q) => q.slice(0, -1));
263
+ setCursor(0);
264
+ } else if (key.name === "up") {
265
+ move(-1);
266
+ } else if (key.name === "down") {
267
+ move(1);
268
+ } else {
269
+ // A single char, or a paste delivered as one key chunk. Reject escape
270
+ // sequences (special keys) — they always contain an Esc byte.
271
+ const ch = key.sequence;
272
+ if (ch && !key.ctrl && !key.meta && !ch.includes("\x1b")) {
273
+ const text = printable(ch);
274
+ if (text) {
275
+ setQuery((q) => q + text);
276
+ setCursor(0);
277
+ }
278
+ }
279
+ }
280
+ return;
281
+ }
282
+
283
+ switch (key.name) {
284
+ case "up":
285
+ case "k":
286
+ move(-1);
287
+ break;
288
+ case "down":
289
+ case "j":
290
+ move(1);
291
+ break;
292
+ case "return":
293
+ submit();
294
+ break;
295
+ case "space":
296
+ case "tab":
297
+ if (multiSelect) toggleMark();
298
+ break;
299
+ case "v":
300
+ if (multiSelect) toggleVisual();
301
+ break;
302
+ case "[":
303
+ if (onReorder) reorder(-1);
304
+ else onExtraKey?.(key.name, ref.current.filtered[ref.current.cur] ?? null);
305
+ break;
306
+ case "]":
307
+ if (onReorder) reorder(1);
308
+ else onExtraKey?.(key.name, ref.current.filtered[ref.current.cur] ?? null);
309
+ break;
310
+ case "/":
311
+ setFiltering(true);
312
+ setQuery("");
313
+ setCursor(0);
314
+ break;
315
+ case "escape":
316
+ if (immediateCancel) {
317
+ // Transient picker: Esc goes back in a single press, no prompt.
318
+ onCancel?.();
319
+ } else if (ref.current.anchor !== null) {
320
+ // Esc peels back state one step at a time: cancel a visual sweep, then
321
+ // clear the selection, and only once nothing is selected does a second
322
+ // Esc exit (the first arms the "press again" prompt).
323
+ setAnchor(null);
324
+ } else if (ref.current.marked.size > 0) {
325
+ setMarked(new Set());
326
+ disarmEsc();
327
+ } else if (ref.current.escArmed) onCancel?.();
328
+ else armEsc();
329
+ break;
330
+ case "q":
331
+ onCancel?.();
332
+ break;
333
+ case "t":
334
+ cycleTheme();
335
+ break;
336
+ default:
337
+ onExtraKey?.(key.name, ref.current.filtered[ref.current.cur] ?? null);
338
+ }
339
+ });
340
+
341
+ // Per-section counts across the whole filtered list (for header labels).
342
+ const sectionCounts = useMemo(() => {
343
+ const counts = new Map<string, number>();
344
+ if (sectionOf) {
345
+ for (const it of filtered) {
346
+ const s = sectionOf(it);
347
+ counts.set(s, (counts.get(s) ?? 0) + 1);
348
+ }
349
+ }
350
+ return counts;
351
+ }, [filtered, sectionOf]);
352
+
353
+ const headerFor = (id: string) =>
354
+ sectionLabel ? sectionLabel(id, sectionCounts.get(id) ?? 0) : `${id} (${sectionCounts.get(id) ?? 0})`;
355
+
356
+ // Viewport windowing around the cursor.
357
+ const { height } = useTerminalDimensions();
358
+ const maxVisible = Math.max(3, props.maxVisible ?? height - 11);
359
+ const total = filtered.length;
360
+ let start = 0;
361
+ if (total > maxVisible) {
362
+ start = Math.min(Math.max(0, cur - Math.floor(maxVisible / 2)), total - maxVisible);
363
+ }
364
+ const visible = filtered.slice(start, start + maxVisible);
365
+
366
+ const selectedCount = effective.size;
367
+
368
+ return (
369
+ <box
370
+ style={{ flexDirection: "column" }}
371
+ onMouseScroll={(e) => {
372
+ if (!active) return;
373
+ const dir = e.scroll?.direction;
374
+ if (dir === "up") scrollBy(-1);
375
+ else if (dir === "down") scrollBy(1);
376
+ }}
377
+ >
378
+ {filtering ? (
379
+ <text>
380
+ <span fg={theme.accent}>/ </span>
381
+ {query}
382
+ <span fg={theme.dim}>_</span>
383
+ </text>
384
+ ) : null}
385
+
386
+ {multiSelect && (selectedCount > 0 || anchor !== null) ? (
387
+ <text>
388
+ <span fg={theme.green}>{`* ${selectedCount} selected`}</span>
389
+ {anchor !== null ? <span fg={theme.yellow}>{" VISUAL - v to keep, Esc to cancel"}</span> : null}
390
+ </text>
391
+ ) : null}
392
+
393
+ {total === 0 ? <text fg={theme.dim}>{emptyText}</text> : null}
394
+ {start > 0 ? <text fg={theme.dim}>{` ^ ${start} more`}</text> : null}
395
+
396
+ {visible.map((item, i) => {
397
+ const idx = start + i;
398
+ const selected = idx === cur;
399
+ const k = getKey(item);
400
+ const isMarked = effective.has(k);
401
+ const prev = i > 0 ? visible[i - 1] : undefined;
402
+ const section = sectionOf?.(item);
403
+ const showHeader =
404
+ section !== undefined && (prev === undefined || sectionOf?.(prev) !== section);
405
+ return (
406
+ <box key={k} style={{ flexDirection: "column" }}>
407
+ {showHeader ? (
408
+ <text fg={theme.accentDim}>{headerFor(section!)}</text>
409
+ ) : null}
410
+ <box
411
+ style={{ flexDirection: "row", backgroundColor: selected ? theme.selBg : undefined }}
412
+ onMouseDown={() => {
413
+ if (active) clickRow(idx);
414
+ }}
415
+ onMouseOver={() => {
416
+ if (active) setCursor(idx); // hover moves the highlight to this row
417
+ }}
418
+ >
419
+ <text fg={selected ? theme.accent : theme.dim}>{selected ? "> " : " "}</text>
420
+ {multiSelect ? (
421
+ <text fg={isMarked ? theme.green : theme.dim}>{isMarked ? "* " : "- "}</text>
422
+ ) : null}
423
+ {renderRow(item, { selected, marked: isMarked })}
424
+ </box>
425
+ </box>
426
+ );
427
+ })}
428
+
429
+ {start + maxVisible < total ? (
430
+ <text fg={theme.dim}>{` v ${total - start - maxVisible} more`}</text>
431
+ ) : null}
432
+
433
+ {escArmed ? <text fg={theme.yellow}>Press Esc again to exit</text> : null}
434
+ </box>
435
+ );
436
+ }
@@ -0,0 +1,83 @@
1
+ // TextPrompt — a single-line text input. Owns its own keyboard handler (mirrors
2
+ // ListSelect's "/" filter), so render it only while it should capture input and
3
+ // set any underlying list to `active={false}`. Give each use a distinct React
4
+ // `key` when the `initial` value differs between steps, so it remounts fresh.
5
+ //
6
+ // Accepts both typed characters and pasted text — the terminal delivers a paste
7
+ // either as a bracketed-paste event (usePaste) or as one multi-character key
8
+ // chunk, so we handle both.
9
+
10
+ import { useState } from "react";
11
+ import { useKeyboard, usePaste } from "@opentui/react";
12
+ import { useTheme } from "../theme-context";
13
+
14
+ // Drop control chars (incl. CR/LF/Esc) so pasted/typed text stays single-line.
15
+ const printable = (s: string) => s.replace(/[\x00-\x1f]/g, "");
16
+
17
+ export function TextPrompt({
18
+ label,
19
+ initial = "",
20
+ placeholder,
21
+ active = true,
22
+ onSubmit,
23
+ onCancel,
24
+ }: {
25
+ label: string;
26
+ initial?: string;
27
+ placeholder?: string;
28
+ active?: boolean;
29
+ onSubmit: (value: string) => void;
30
+ onCancel: () => void;
31
+ }) {
32
+ const theme = useTheme();
33
+ const [value, setValue] = useState(initial);
34
+
35
+ usePaste((event) => {
36
+ if (!active) return;
37
+ const text = printable(new TextDecoder().decode(event.bytes));
38
+ if (text) setValue((v) => v + text);
39
+ });
40
+
41
+ useKeyboard((key) => {
42
+ if (!active) return;
43
+ if (key.name === "escape") {
44
+ onCancel();
45
+ } else if (key.name === "return") {
46
+ onSubmit(value);
47
+ } else if (key.name === "backspace") {
48
+ setValue((v) => v.slice(0, -1));
49
+ } else {
50
+ // A single char, or a paste delivered as one key chunk. Reject escape
51
+ // sequences (special keys) — they always contain an Esc byte.
52
+ const ch = key.sequence;
53
+ if (ch && !key.ctrl && !key.meta && !ch.includes("\x1b")) {
54
+ const text = printable(ch);
55
+ if (text) setValue((v) => v + text);
56
+ }
57
+ }
58
+ });
59
+
60
+ return (
61
+ <box
62
+ style={{
63
+ flexDirection: "column",
64
+ border: true,
65
+ borderStyle: "rounded",
66
+ borderColor: theme.accent,
67
+ padding: 1,
68
+ marginTop: 1,
69
+ }}
70
+ >
71
+ <text fg={theme.accent}>{label}</text>
72
+ <text>
73
+ {value ? (
74
+ <span fg={theme.fg}>{value}</span>
75
+ ) : (
76
+ <span fg={theme.dim}>{placeholder ?? ""}</span>
77
+ )}
78
+ <span fg={theme.dim}>_</span>
79
+ </text>
80
+ <text fg={theme.dim}>enter confirm | esc cancel | paste supported</text>
81
+ </box>
82
+ );
83
+ }
@@ -0,0 +1,7 @@
1
+ export { ListSelect } from "./ListSelect";
2
+ export type { ListSelectProps } from "./ListSelect";
3
+ export { Header } from "./Header";
4
+ export { Confirm } from "./Confirm";
5
+ export { Help } from "./Help";
6
+ export type { Binding } from "./Help";
7
+ export { TextPrompt } from "./TextPrompt";
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bun
2
+ // devkit — the hub. Lists every tool and runs the chosen one.
3
+ //
4
+ // Each tool is launched as a child process (`bun pkg/tui/<tool>.tsx`) with the
5
+ // terminal inherited, so it gets a clean screen and can hand off / take over
6
+ // stdio as needed. When the tool exits, the hub menu comes back. The same tool
7
+ // files run standalone, so `killport` / `launch` work without going through `devkit`.
8
+
9
+ import { useState } from "react";
10
+ import { join } from "node:path";
11
+ import { mountScreen } from "./app";
12
+ import { useTheme } from "./theme-context";
13
+ import { Header, ListSelect, Help, type Binding } from "./components";
14
+ import { TOOLS, type ToolDef } from "./tools";
15
+
16
+ const HELP: Binding[] = [
17
+ { keys: "j / k", desc: "move (or arrow keys)" },
18
+ { keys: "/", desc: "filter tools" },
19
+ { keys: "enter", desc: "run the selected tool" },
20
+ { keys: "t", desc: "cycle color theme" },
21
+ { keys: "q / esc esc", desc: "quit (q, or Esc twice)" },
22
+ ];
23
+
24
+ function ToolRow({ t, selected }: { t: ToolDef; selected: boolean }) {
25
+ const theme = useTheme();
26
+ return (
27
+ <text>
28
+ <span fg={selected ? theme.selFg : theme.fg}>{t.label.padEnd(12)}</span>
29
+ <span fg={theme.dim}>{t.description}</span>
30
+ </text>
31
+ );
32
+ }
33
+
34
+ function DevkitHub({ onPick }: { onPick: (t: ToolDef | null) => void }) {
35
+ const [showHelp, setShowHelp] = useState(false);
36
+ if (showHelp) {
37
+ return <Help title="devkit - keys" bindings={HELP} onClose={() => setShowHelp(false)} />;
38
+ }
39
+ return (
40
+ <box style={{ padding: 1, flexDirection: "column" }}>
41
+ <Header
42
+ title="devkit"
43
+ subtitle="developer toolbelt"
44
+ hint="j/k move | / filter | enter run | t theme | h help | q quit"
45
+ />
46
+ <ListSelect
47
+ items={TOOLS}
48
+ getKey={(t) => t.id}
49
+ filterText={(t) => `${t.label} ${t.description}`}
50
+ active={!showHelp}
51
+ emptyText="No tools registered."
52
+ onSubmit={(items) => onPick(items[0] ?? null)}
53
+ onCancel={() => onPick(null)}
54
+ onExtraKey={(name) => {
55
+ if (name === "h") setShowHelp(true);
56
+ }}
57
+ renderRow={(t, { selected }) => <ToolRow t={t} selected={selected} />}
58
+ />
59
+ </box>
60
+ );
61
+ }
62
+
63
+ export async function runHub() {
64
+ for (;;) {
65
+ const picked = await mountScreen<ToolDef | null>((done) => <DevkitHub onPick={done} />);
66
+ if (!picked) break;
67
+ const child = Bun.spawn(["bun", join(import.meta.dir, picked.file)], {
68
+ stdin: "inherit",
69
+ stdout: "inherit",
70
+ stderr: "inherit",
71
+ });
72
+ await child.exited;
73
+ }
74
+ process.exit(0);
75
+ }
76
+
77
+ if (import.meta.main) {
78
+ await runHub();
79
+ }