@garage49/garage49-tui-ink 0.0.0-stage → 0.1.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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/components/Button.d.ts +8 -0
  4. package/dist/components/Button.js +13 -0
  5. package/dist/components/Checkbox.d.ts +10 -0
  6. package/dist/components/Checkbox.js +18 -0
  7. package/dist/components/Chip.d.ts +14 -0
  8. package/dist/components/Chip.js +25 -0
  9. package/dist/components/ConfirmDialog.d.ts +13 -0
  10. package/dist/components/ConfirmDialog.js +18 -0
  11. package/dist/components/Dropdown.d.ts +13 -0
  12. package/dist/components/Dropdown.js +25 -0
  13. package/dist/components/Editing.d.ts +11 -0
  14. package/dist/components/Editing.js +16 -0
  15. package/dist/components/FieldBar.d.ts +12 -0
  16. package/dist/components/FieldBar.js +11 -0
  17. package/dist/components/FocusRegion.d.ts +27 -0
  18. package/dist/components/FocusRegion.js +30 -0
  19. package/dist/components/Form.d.ts +27 -0
  20. package/dist/components/Form.js +29 -0
  21. package/dist/components/HelpOverlay.d.ts +11 -0
  22. package/dist/components/HelpOverlay.js +13 -0
  23. package/dist/components/Input.d.ts +18 -0
  24. package/dist/components/Input.js +31 -0
  25. package/dist/components/Intro.d.ts +12 -0
  26. package/dist/components/Intro.js +13 -0
  27. package/dist/components/KeyHintBar.d.ts +13 -0
  28. package/dist/components/KeyHintBar.js +23 -0
  29. package/dist/components/Label.d.ts +11 -0
  30. package/dist/components/Label.js +18 -0
  31. package/dist/components/List.d.ts +23 -0
  32. package/dist/components/List.js +42 -0
  33. package/dist/components/ListLayout.d.ts +20 -0
  34. package/dist/components/ListLayout.js +23 -0
  35. package/dist/components/LogView.d.ts +15 -0
  36. package/dist/components/LogView.js +27 -0
  37. package/dist/components/MasterDetail.d.ts +20 -0
  38. package/dist/components/MasterDetail.js +17 -0
  39. package/dist/components/MessageDialog.d.ts +9 -0
  40. package/dist/components/MessageDialog.js +11 -0
  41. package/dist/components/Overlay.d.ts +10 -0
  42. package/dist/components/Overlay.js +9 -0
  43. package/dist/components/Palette.d.ts +10 -0
  44. package/dist/components/Palette.js +26 -0
  45. package/dist/components/Panel.d.ts +10 -0
  46. package/dist/components/Panel.js +8 -0
  47. package/dist/components/RadioGroup.d.ts +18 -0
  48. package/dist/components/RadioGroup.js +46 -0
  49. package/dist/components/Screen.d.ts +23 -0
  50. package/dist/components/Screen.js +35 -0
  51. package/dist/components/Scrollbar.d.ts +16 -0
  52. package/dist/components/Scrollbar.js +24 -0
  53. package/dist/components/Section.d.ts +15 -0
  54. package/dist/components/Section.js +15 -0
  55. package/dist/components/Select.d.ts +14 -0
  56. package/dist/components/Select.js +38 -0
  57. package/dist/components/Selection.d.ts +6 -0
  58. package/dist/components/Selection.js +13 -0
  59. package/dist/components/Spinner.d.ts +9 -0
  60. package/dist/components/Spinner.js +16 -0
  61. package/dist/components/Split.d.ts +13 -0
  62. package/dist/components/Split.js +12 -0
  63. package/dist/components/StatusLine.d.ts +12 -0
  64. package/dist/components/StatusLine.js +32 -0
  65. package/dist/components/Table.d.ts +19 -0
  66. package/dist/components/Table.js +31 -0
  67. package/dist/components/Tabs.d.ts +20 -0
  68. package/dist/components/Tabs.js +37 -0
  69. package/dist/components/TextArea.d.ts +20 -0
  70. package/dist/components/TextArea.js +74 -0
  71. package/dist/components/TextBuffer.d.ts +17 -0
  72. package/dist/components/TextBuffer.js +62 -0
  73. package/dist/components/TextField.d.ts +14 -0
  74. package/dist/components/TextField.js +36 -0
  75. package/dist/components/Toggle.d.ts +11 -0
  76. package/dist/components/Toggle.js +18 -0
  77. package/dist/components/TopNav.d.ts +18 -0
  78. package/dist/components/TopNav.js +19 -0
  79. package/dist/components/TreeClick.d.ts +11 -0
  80. package/dist/components/TreeClick.js +13 -0
  81. package/dist/components/TreeView.d.ts +40 -0
  82. package/dist/components/TreeView.js +76 -0
  83. package/dist/index.d.ts +57 -0
  84. package/dist/index.js +66 -0
  85. package/dist/input/KeyChord.d.ts +11 -0
  86. package/dist/input/KeyChord.js +27 -0
  87. package/dist/input/Keys.d.ts +18 -0
  88. package/dist/input/Keys.js +21 -0
  89. package/dist/input/Mouse.d.ts +50 -0
  90. package/dist/input/Mouse.js +119 -0
  91. package/dist/input/MouseParser.d.ts +18 -0
  92. package/dist/input/MouseParser.js +28 -0
  93. package/dist/shell/App.d.ts +30 -0
  94. package/dist/shell/App.js +44 -0
  95. package/dist/shell/AppActions.d.ts +14 -0
  96. package/dist/shell/AppActions.js +49 -0
  97. package/dist/shell/BottomBars.d.ts +16 -0
  98. package/dist/shell/BottomBars.js +34 -0
  99. package/dist/shell/Content.d.ts +8 -0
  100. package/dist/shell/Content.js +14 -0
  101. package/dist/shell/Fit.d.ts +18 -0
  102. package/dist/shell/Fit.js +17 -0
  103. package/dist/shell/FocusRegistry.d.ts +51 -0
  104. package/dist/shell/FocusRegistry.js +85 -0
  105. package/dist/shell/Main.d.ts +8 -0
  106. package/dist/shell/Main.js +17 -0
  107. package/dist/shell/Nav.d.ts +11 -0
  108. package/dist/shell/Nav.js +23 -0
  109. package/dist/shell/Overlay.d.ts +2 -0
  110. package/dist/shell/Overlay.js +5 -0
  111. package/dist/shell/Sidebar.d.ts +10 -0
  112. package/dist/shell/Sidebar.js +33 -0
  113. package/dist/shell/Status.d.ts +11 -0
  114. package/dist/shell/Status.js +11 -0
  115. package/dist/shell/Typing.d.ts +13 -0
  116. package/dist/shell/Typing.js +20 -0
  117. package/dist/shell/index.d.ts +10 -0
  118. package/dist/shell/index.js +10 -0
  119. package/dist/shell/run.d.ts +11 -0
  120. package/dist/shell/run.js +15 -0
  121. package/dist/theme/Glyphs.d.ts +19 -0
  122. package/dist/theme/Glyphs.js +19 -0
  123. package/dist/theme/TextWidth.d.ts +7 -0
  124. package/dist/theme/TextWidth.js +24 -0
  125. package/dist/theme/Theme.d.ts +35 -0
  126. package/dist/theme/Theme.js +68 -0
  127. package/dist/theme/ThemeContext.d.ts +14 -0
  128. package/dist/theme/ThemeContext.js +20 -0
  129. package/package.json +40 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 garage49
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # @garage49/garage49-tui-ink
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The garage49 TUI design system for Node.js: OpenCode's look and feel as [Ink](https://github.com/vadimdemedes/ink) components, with an application shell that gives every app the same navigation, focus, mouse, overlays and status bars.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @garage49/garage49-tui-ink ink react # or npm install
9
+ ```
10
+
11
+ Requires Node.js 20 or later, Ink 8 and React 19.
12
+
13
+ ## Use
14
+
15
+ ```tsx
16
+ import {useState} from 'react';
17
+ import {App, Content, Main, Nav, Sidebar, Table, run, useFocused, useStatus} from '@garage49/garage49-tui-ink';
18
+
19
+ const views = [{id: 'work', label: 'Work'}, {id: 'settings', label: 'Settings'}];
20
+ const pages = [{id: 'projects', label: 'Projects', section: 'Browse'}, {id: 'log', label: 'Activity', section: 'Browse'}];
21
+
22
+ function ProjectsPage() {
23
+ const focused = useFocused();
24
+ const {report} = useStatus();
25
+ const [selected, setSelected] = useState('garage49-tui');
26
+ return (
27
+ <Table
28
+ columns={[{id: 'name', title: 'Name', width: 24, cell: (p: {name: string}) => p.name}]}
29
+ rows={[{name: 'garage49-tui'}, {name: 'skills'}]}
30
+ rowId={p => p.name}
31
+ selectedId={selected}
32
+ focused={focused}
33
+ onSelect={p => setSelected(p.name)}
34
+ onClick={p => report(`open: ${p.name}`)}
35
+ />
36
+ );
37
+ }
38
+
39
+ function MyApp() {
40
+ const [view, setView] = useState('work');
41
+ const [page, setPage] = useState('projects');
42
+ return (
43
+ <App context="myapp">
44
+ <Nav brand="myapp" items={views} activeId={view} onChange={item => setView(item.id)} right="v1.0.0" />
45
+ <Content>
46
+ {view === 'work' && <Sidebar items={pages} selectedId={page} onSelect={item => setPage(item.id)} />}
47
+ <Main title="Projects"><ProjectsPage /></Main>
48
+ </Content>
49
+ </App>
50
+ );
51
+ }
52
+
53
+ run(<MyApp />);
54
+ ```
55
+
56
+ ## Rules of use
57
+
58
+ Read these before writing app code.
59
+
60
+ 1. **The root renders `App` and nothing else.** `useOverlay`, `useStatus`, `useTyping` and `useFocused` are contexts that `App` provides, so they work only in App's children. Put the app body, the pages and every hook call in child components.
61
+ 2. **Running inside an existing event loop.** `run()` calls Ink's `render` and returns its `Instance`; it does not block. Keep servers and timers on the same Node event loop and call `instance.unmount()` (or `exit` from `useApp`) to leave.
62
+ 3. **Pushing a background task's state into the UI.** Hold the task's state in React state (or an external store read with `useSyncExternalStore`) in a component under `App`, and update it from the task's callbacks; never render from the task.
63
+ 4. **Page letter keys yield while a field is editing.** A page that binds letters (`j k e d s …`) must pass `isActive: focused && !typing` (read it with `useTypingState()` from the shell) or those keys also type into the editing field. App's own `q ? t m` already yield.
64
+ 5. **Editor round trips.** Use Ink's `useApp().suspendTerminal()` (Ink 8) around the external program; the shell does not wrap it yet.
65
+
66
+ `run` mounts the app on the alternate screen with the theme that fits the terminal (OpenCode's truecolor theme, or the system theme without truecolor) and restores the terminal on exit. `App` provides focus cycling (tab, enter/esc), the mouse, the overlay slot, the command palette (ctrl+p), help (?), the quit confirmation (q), theme (t) and mouse (m) toggles, the status line and the key hint bar.
67
+
68
+ ## Build
69
+
70
+ ```bash
71
+ npm run build
72
+ ```
73
+
74
+ Emits `dist/` with JavaScript and type declarations.
75
+
76
+ ## Test
77
+
78
+ Unit tests:
79
+
80
+ ```bash
81
+ npm test
82
+ ```
83
+
84
+ Complexity check (ESLint: cyclomatic complexity 10, 50 lines per function, 5 parameters):
85
+
86
+ ```bash
87
+ npm run complexity
88
+ ```
89
+
90
+ `npm run check` runs the type check, the complexity check and the tests.
91
+
92
+ ## Run
93
+
94
+ The gallery in `samples/node` of the repository shows every component:
95
+
96
+ ```bash
97
+ pnpm gallery
98
+ ```
@@ -0,0 +1,8 @@
1
+ type Props = {
2
+ label: string;
3
+ selected?: boolean;
4
+ onPress?: () => void;
5
+ };
6
+ /** A clickable label on a raised surface; the selected button is filled with the accent color. */
7
+ export declare function Button({ label, selected, onPress }: Props): import("react").JSX.Element;
8
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useRef } from 'react';
4
+ import { useMouseTarget } from '../input/Mouse.js';
5
+ import { useTheme } from '../theme/ThemeContext.js';
6
+ /** A clickable label on a raised surface; the selected button is filled with the accent color. */
7
+ export function Button({ label, selected = false, onPress }) {
8
+ const theme = useTheme();
9
+ const box = useRef(null);
10
+ useMouseTarget(box, { onPress: event => { if (event.button === 'left')
11
+ onPress?.(); } });
12
+ return (_jsx(Box, { ref: box, backgroundColor: selected ? theme.tokens.selectionBackground : theme.tokens.surfaceRaised, paddingX: 2, marginLeft: 2, children: _jsx(Text, { bold: selected, color: selected ? theme.tokens.selectionText : theme.tokens.text, children: label }) }));
13
+ }
@@ -0,0 +1,10 @@
1
+ type Props = {
2
+ label: string;
3
+ checked: boolean;
4
+ onChange?: (checked: boolean) => void;
5
+ focused?: boolean;
6
+ onFocus?: () => void;
7
+ };
8
+ /** A check box on the screen background: ☑ in the success color when checked, ☐ muted otherwise. Space or click toggles. */
9
+ export declare function Checkbox({ label, checked, onChange, focused, onFocus }: Props): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useRef } from 'react';
4
+ import { useKeys } from '../input/Keys.js';
5
+ import { useMouseTarget } from '../input/Mouse.js';
6
+ import { Glyphs } from '../theme/Glyphs.js';
7
+ import { useTheme } from '../theme/ThemeContext.js';
8
+ import { FieldBar } from './FieldBar.js';
9
+ import { useSurface } from './FocusRegion.js';
10
+ /** A check box on the screen background: ☑ in the success color when checked, ☐ muted otherwise. Space or click toggles. */
11
+ export function Checkbox({ label, checked, onChange, focused = false, onFocus }) {
12
+ const theme = useTheme();
13
+ const surface = useSurface();
14
+ const box = useRef(null);
15
+ useMouseTarget(box, { onPress: () => { onFocus?.(); onChange?.(!checked); } });
16
+ useKeys([{ keys: ['space', 'enter'], run: () => onChange?.(!checked) }], { isActive: focused });
17
+ return (_jsxs(Box, { ref: box, flexDirection: "row", height: 1, children: [_jsx(FieldBar, { focused: focused, surface: surface }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, color: checked ? theme.tokens.success : theme.tokens.textMuted, children: [checked ? Glyphs.checked : Glyphs.unchecked, " "] }), _jsx(Text, { color: focused ? theme.tokens.text : theme.tokens.textMuted, children: label })] }));
18
+ }
@@ -0,0 +1,14 @@
1
+ export type ChipTone = 'default' | 'accent' | 'success' | 'warning' | 'error';
2
+ type Props = {
3
+ label: string;
4
+ /** Colors the label; the pill stays on the raised surface so tone reads as a state, not a cursor. */
5
+ tone?: ChipTone;
6
+ /** The cursor is on this chip: filled with the accent color. */
7
+ selected?: boolean;
8
+ /** Shows a × and calls onRemove when it is clicked. */
9
+ onRemove?: () => void;
10
+ onPress?: () => void;
11
+ };
12
+ /** A small pill on the raised surface: a tag, a filter, a status. Click selects; × removes. */
13
+ export declare function Chip({ label, tone, selected, onRemove, onPress }: Props): import("react").JSX.Element;
14
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useRef } from 'react';
4
+ import { useMouseTarget } from '../input/Mouse.js';
5
+ import { useTheme } from '../theme/ThemeContext.js';
6
+ /** A small pill on the raised surface: a tag, a filter, a status. Click selects; × removes. */
7
+ export function Chip({ label, tone = 'default', selected = false, onRemove, onPress }) {
8
+ const theme = useTheme();
9
+ const box = useRef(null);
10
+ const tones = {
11
+ default: theme.tokens.text,
12
+ accent: theme.tokens.accent,
13
+ success: theme.tokens.success,
14
+ warning: theme.tokens.warning,
15
+ error: theme.tokens.error,
16
+ };
17
+ useMouseTarget(box, {
18
+ onPress: event => {
19
+ if (onRemove && event.localX >= [...label].length + 2)
20
+ return onRemove();
21
+ onPress?.();
22
+ },
23
+ });
24
+ return (_jsxs(Box, { ref: box, paddingX: 1, marginRight: 1, backgroundColor: selected ? theme.tokens.selectionBackground : theme.tokens.surfaceRaised, children: [_jsx(Text, { bold: selected, color: selected ? theme.tokens.selectionText : tones[tone], children: label }), onRemove && _jsx(Text, { color: selected ? theme.tokens.selectionText : theme.tokens.textMuted, children: " \u00D7" })] }));
25
+ }
@@ -0,0 +1,13 @@
1
+ type Props = {
2
+ title: string;
3
+ message: string;
4
+ confirmLabel?: string;
5
+ cancelLabel?: string;
6
+ /** Marks the confirming action as destructive: the dialog title turns to the error color. */
7
+ danger?: boolean;
8
+ onConfirm: () => void;
9
+ onCancel: () => void;
10
+ };
11
+ /** A yes/no dialog: message, then two buttons; the chosen button is filled with the accent color. */
12
+ export declare function ConfirmDialog({ title, message, confirmLabel, cancelLabel, danger, onConfirm, onCancel }: Props): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { useState } from 'react';
4
+ import { useKeys } from '../input/Keys.js';
5
+ import { Button } from './Button.js';
6
+ import { Label } from './Label.js';
7
+ import { Overlay } from './Overlay.js';
8
+ /** A yes/no dialog: message, then two buttons; the chosen button is filled with the accent color. */
9
+ export function ConfirmDialog({ title, message, confirmLabel = 'OK', cancelLabel = 'Cancel', danger = false, onConfirm, onCancel }) {
10
+ const [confirmSelected, setConfirmSelected] = useState(false);
11
+ useKeys([
12
+ { keys: ['esc', 'n'], run: onCancel },
13
+ { keys: ['y'], run: onConfirm },
14
+ { keys: ['left', 'right', 'tab'], run: () => setConfirmSelected(!confirmSelected) },
15
+ { keys: ['enter'], run: () => (confirmSelected ? onConfirm() : onCancel()) },
16
+ ]);
17
+ return (_jsxs(Overlay, { title: title, width: 50, variant: danger ? 'error' : 'default', children: [_jsx(Box, { children: _jsx(Label, { children: message }) }), _jsx(Box, { height: 1 }), _jsxs(Box, { flexDirection: "row", justifyContent: "flex-end", children: [_jsx(Button, { label: cancelLabel, selected: !confirmSelected, onPress: onCancel }), _jsx(Button, { label: confirmLabel, selected: confirmSelected, onPress: onConfirm })] })] }));
18
+ }
@@ -0,0 +1,13 @@
1
+ import { type ListItem } from './List.js';
2
+ type Props = {
3
+ items: readonly ListItem[];
4
+ /** The row the cursor starts on; the first row otherwise. */
5
+ initialId?: string;
6
+ onPick: (item: ListItem) => void;
7
+ onClose: () => void;
8
+ /** Left/right arrow: move to a neighbouring dropdown (menus). Absent, the arrows do nothing. */
9
+ onNavigate?: (delta: -1 | 1) => void;
10
+ };
11
+ /** A list popover on a raised surface, under a menu label or a select box. Owns its cursor and keys. */
12
+ export declare function Dropdown({ items, initialId, onPick, onClose, onNavigate }: Props): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { useState } from 'react';
4
+ import { Selection } from './Selection.js';
5
+ import { useKeys } from '../input/Keys.js';
6
+ import { TextWidth } from '../theme/TextWidth.js';
7
+ import { useTheme } from '../theme/ThemeContext.js';
8
+ import { List } from './List.js';
9
+ /** A list popover on a raised surface, under a menu label or a select box. Owns its cursor and keys. */
10
+ export function Dropdown({ items, initialId, onPick, onClose, onNavigate }) {
11
+ const theme = useTheme();
12
+ const [cursorId, setCursorId] = useState(initialId);
13
+ const cursor = Selection.ensure(items, cursorId);
14
+ useKeys([
15
+ { keys: ['esc'], run: onClose },
16
+ { keys: ['left'], run: () => onNavigate?.(-1) },
17
+ { keys: ['right'], run: () => onNavigate?.(1) },
18
+ { keys: ['up'], run: () => setCursorId(id => Selection.move(items, Selection.ensure(items, id), -1)) },
19
+ { keys: ['down'], run: () => setCursorId(id => Selection.move(items, Selection.ensure(items, id), 1)) },
20
+ { keys: ['enter'], run: () => { const item = items.find(candidate => candidate.id === cursor); if (item)
21
+ onPick(item); } },
22
+ ]);
23
+ const widest = TextWidth.widest(items.map(item => item.label + (item.shortcut ? ` ${item.shortcut}` : '')));
24
+ return (_jsx(Box, { flexDirection: "column", backgroundColor: theme.tokens.surface, width: widest + 4, children: _jsx(List, { items: items, selectedId: cursor, onClick: onPick }) }));
25
+ }
@@ -0,0 +1,11 @@
1
+ import type { DOMElement } from 'ink';
2
+ import { type RefObject } from 'react';
3
+ /**
4
+ * Whether a text field is editing: it is the page's current field (`focused`) and no click has landed
5
+ * outside it since. A click outside ends editing (cursor and bar gone, keys back to the app); a click on
6
+ * the field (`resume`) or the field becoming current again starts it anew.
7
+ */
8
+ export declare function useEditing(box: RefObject<DOMElement | null>, focused: boolean): {
9
+ editing: boolean;
10
+ resume: () => void;
11
+ };
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useMouseOutside } from '../input/Mouse.js';
3
+ /**
4
+ * Whether a text field is editing: it is the page's current field (`focused`) and no click has landed
5
+ * outside it since. A click outside ends editing (cursor and bar gone, keys back to the app); a click on
6
+ * the field (`resume`) or the field becoming current again starts it anew.
7
+ */
8
+ export function useEditing(box, focused) {
9
+ const [blurred, setBlurred] = useState(false);
10
+ useEffect(() => setBlurred(false), [focused]); // becoming (or ceasing to be) the current field starts afresh
11
+ useMouseOutside(box, () => {
12
+ if (focused)
13
+ setBlurred(true);
14
+ });
15
+ return { editing: focused && !blurred, resume: () => setBlurred(false) };
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { RegionSurface } from './FocusRegion.js';
2
+ type Props = {
3
+ focused: boolean;
4
+ surface?: RegionSurface; /** Height of the field; the bar covers every row. */
5
+ rows?: number;
6
+ };
7
+ /**
8
+ * The first column of a form field: blank normally, a blue ┃ down every row while the field is being edited.
9
+ * Same rule as FocusRegion, at field scale: the bar is painted over the field's own first column.
10
+ */
11
+ export declare function FieldBar({ focused, surface, rows }: Props): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from '../theme/ThemeContext.js';
4
+ /**
5
+ * The first column of a form field: blank normally, a blue ┃ down every row while the field is being edited.
6
+ * Same rule as FocusRegion, at field scale: the bar is painted over the field's own first column.
7
+ */
8
+ export function FieldBar({ focused, surface = 'surface', rows = 1 }) {
9
+ const theme = useTheme();
10
+ return (_jsx(Box, { width: 1, height: rows, backgroundColor: theme.tokens[surface], children: _jsx(Text, { color: theme.tokens.accentSecondary, children: (focused ? '┃\n' : ' \n').repeat(rows).trimEnd() }) }));
11
+ }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from 'react';
2
+ export type RegionSurface = 'background' | 'panel' | 'surface' | 'heading';
3
+ /** Tells the fields inside which color their container draws, so a bar-only field paints its bar column on it. */
4
+ export declare function SurfaceProvider({ surface, children }: {
5
+ surface: RegionSurface;
6
+ children: ReactNode;
7
+ }): import("react").JSX.Element;
8
+ /** The color of the nearest container: a Section's panel, a MasterDetail detail's surface, an Overlay's panel, else the background. */
9
+ export declare function useSurface(): RegionSurface;
10
+ type Props = {
11
+ focused: boolean;
12
+ /** Take the remaining width of the parent row. */
13
+ grow?: boolean;
14
+ /** What the region draws in its first column; the bar is painted on that color. One value, or one per row (the last repeats). */
15
+ surface?: RegionSurface | readonly RegionSurface[];
16
+ /** How many rows the bar covers from the top; the whole region by default. A header marks only its title row. */
17
+ rows?: number;
18
+ children: ReactNode;
19
+ };
20
+ /**
21
+ * A focusable region. When focused, a column of ┃ in accentSecondary (blue) is painted over the
22
+ * region's first column; otherwise nothing is drawn, so the region looks exactly as it did.
23
+ * The region must keep its first column free (padding-left 1). Ink does not keep the cell's
24
+ * background when a character is painted over it, so the bar repaints the region's surface.
25
+ */
26
+ export declare function FocusRegion({ focused, grow, surface, rows, children }: Props): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useBoxMetrics } from 'ink';
3
+ import { createContext, useContext, useRef } from 'react';
4
+ import { useTheme } from '../theme/ThemeContext.js';
5
+ const SurfaceContext = createContext('background');
6
+ /** Tells the fields inside which color their container draws, so a bar-only field paints its bar column on it. */
7
+ export function SurfaceProvider({ surface, children }) {
8
+ return _jsx(SurfaceContext.Provider, { value: surface, children: children });
9
+ }
10
+ /** The color of the nearest container: a Section's panel, a MasterDetail detail's surface, an Overlay's panel, else the background. */
11
+ export function useSurface() {
12
+ return useContext(SurfaceContext);
13
+ }
14
+ /**
15
+ * A focusable region. When focused, a column of ┃ in accentSecondary (blue) is painted over the
16
+ * region's first column; otherwise nothing is drawn, so the region looks exactly as it did.
17
+ * The region must keep its first column free (padding-left 1). Ink does not keep the cell's
18
+ * background when a character is painted over it, so the bar repaints the region's surface.
19
+ */
20
+ export function FocusRegion({ focused, grow = false, surface = 'background', rows, children }) {
21
+ const theme = useTheme();
22
+ const content = useRef(null);
23
+ const { height } = useBoxMetrics(content);
24
+ const barHeight = Math.max(1, Math.min(height, rows ?? height));
25
+ return (_jsxs(Box, { flexDirection: "row", flexGrow: grow ? 1 : 0, flexShrink: grow ? 1 : 0, children: [_jsx(Box, { ref: content, flexDirection: "column", flexGrow: 1, children: children }), focused && (_jsx(Box, { position: "absolute", top: 0, left: 0, width: 1, height: barHeight, overflow: "hidden", flexDirection: "column", children: Array.from({ length: barHeight }, (_, row) => {
26
+ const surfaces = typeof surface === 'string' ? [surface] : surface;
27
+ const name = surfaces[Math.min(row, surfaces.length - 1)];
28
+ return _jsx(Box, { height: 1, backgroundColor: theme.tokens[name], children: _jsx(Text, { color: theme.tokens.accentSecondary, children: "\u2503" }) }, row);
29
+ }) }))] }));
30
+ }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from 'react';
2
+ /** The two columns every field of a Form shares. */
3
+ export type FormLayout = {
4
+ readonly labelWidth: number;
5
+ readonly valueWidth: number;
6
+ };
7
+ /** The enclosing Form's columns, if any; fields fall back to their own defaults outside a Form. */
8
+ export declare function useFormLayout(): FormLayout | null;
9
+ /** A field's two widths: its own props win, then the enclosing Form, then the gallery defaults (14 / 24). */
10
+ export declare function useFieldColumns(labelWidth?: number, width?: number): {
11
+ labelCol: number;
12
+ valueCol: number;
13
+ };
14
+ type Props = {
15
+ children: ReactNode; /** The form's width; the value column is what the label column leaves. */
16
+ width?: number;
17
+ };
18
+ /** The narrowest label column a Form uses, so short labels still line up with the gallery. */
19
+ export declare const MIN_LABEL_WIDTH = 12;
20
+ /**
21
+ * A group of fields that share one label column and one value column. The label column is as wide
22
+ * as the longest label plus two cells, rounded up to an even number and at least 12; the value column
23
+ * is the rest of the width, so every text field, select and text area in the form is exactly as wide
24
+ * as its neighbours.
25
+ */
26
+ export declare function Form({ children, width }: Props): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { Children, createContext, isValidElement, useContext } from 'react';
4
+ import { TextWidth } from '../theme/TextWidth.js';
5
+ const FormContext = createContext(null);
6
+ /** The enclosing Form's columns, if any; fields fall back to their own defaults outside a Form. */
7
+ export function useFormLayout() {
8
+ return useContext(FormContext);
9
+ }
10
+ /** A field's two widths: its own props win, then the enclosing Form, then the gallery defaults (14 / 24). */
11
+ export function useFieldColumns(labelWidth, width) {
12
+ const form = useContext(FormContext);
13
+ return { labelCol: labelWidth ?? form?.labelWidth ?? 14, valueCol: width ?? form?.valueWidth ?? 24 };
14
+ }
15
+ /** The narrowest label column a Form uses, so short labels still line up with the gallery. */
16
+ export const MIN_LABEL_WIDTH = 12;
17
+ /**
18
+ * A group of fields that share one label column and one value column. The label column is as wide
19
+ * as the longest label plus two cells, rounded up to an even number and at least 12; the value column
20
+ * is the rest of the width, so every text field, select and text area in the form is exactly as wide
21
+ * as its neighbours.
22
+ */
23
+ export function Form({ children, width = 60 }) {
24
+ const labels = Children.toArray(children).flatMap(child => (isValidElement(child) && typeof child.props.label === 'string' ? [child.props.label] : []));
25
+ // Indents move in steps of two cells, so the label column is rounded up to an even width.
26
+ const labelWidth = Math.ceil(Math.max(MIN_LABEL_WIDTH, TextWidth.widest(labels) + 2) / 2) * 2;
27
+ const valueWidth = Math.max(8, width - labelWidth - 1);
28
+ return (_jsx(FormContext.Provider, { value: { labelWidth, valueWidth }, children: _jsx(Box, { flexDirection: "column", width: width, flexShrink: 0, children: children }) }));
29
+ }
@@ -0,0 +1,11 @@
1
+ export type HelpEntry = {
2
+ readonly keys: string;
3
+ readonly action: string;
4
+ };
5
+ type Props = {
6
+ entries: readonly HelpEntry[];
7
+ onClose: () => void;
8
+ };
9
+ /** The `?` help overlay: one line per key binding. */
10
+ export declare function HelpOverlay({ entries, onClose }: Props): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useKeys } from '../input/Keys.js';
4
+ import { TextWidth } from '../theme/TextWidth.js';
5
+ import { useTheme } from '../theme/ThemeContext.js';
6
+ import { Overlay } from './Overlay.js';
7
+ /** The `?` help overlay: one line per key binding. */
8
+ export function HelpOverlay({ entries, onClose }) {
9
+ const theme = useTheme();
10
+ useKeys([{ keys: ['esc', '?', 'q'], run: onClose }]);
11
+ const keyWidth = TextWidth.widest(entries.map(entry => entry.keys)) + 2;
12
+ return (_jsx(Overlay, { title: "Help", children: _jsx(Box, { flexDirection: "column", children: entries.map(entry => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: keyWidth, flexShrink: 0, children: _jsx(Text, { color: theme.tokens.text, children: entry.keys }) }), _jsx(Text, { color: theme.tokens.textMuted, children: entry.action })] }, entry.keys))) }) }));
13
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ type Props = {
3
+ value: string;
4
+ onChange?: (value: string) => void;
5
+ onSubmit?: (value: string) => void;
6
+ placeholder?: string;
7
+ focused?: boolean;
8
+ /** Mouse click inside the field. */
9
+ onFocus?: () => void;
10
+ /** Secondary line under the text, e.g. mode and model. */
11
+ footer?: ReactNode;
12
+ };
13
+ /**
14
+ * OpenCode's input: a surface behind the text with a half-block bottom edge; while focused a blue ┃
15
+ * is painted over the surface's first column (the FocusRegion rule at field scale).
16
+ */
17
+ export declare function Input({ value, onChange, onSubmit, placeholder, focused, onFocus, footer }: Props): import("react").JSX.Element;
18
+ export {};
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useRef } from 'react';
4
+ import { useKeys } from '../input/Keys.js';
5
+ import { useMouseTarget } from '../input/Mouse.js';
6
+ import { useTyping } from '../shell/Typing.js';
7
+ import { useTheme } from '../theme/ThemeContext.js';
8
+ /**
9
+ * OpenCode's input: a surface behind the text with a half-block bottom edge; while focused a blue ┃
10
+ * is painted over the surface's first column (the FocusRegion rule at field scale).
11
+ */
12
+ export function Input({ value, onChange, onSubmit, placeholder = '', focused = true, onFocus, footer }) {
13
+ const theme = useTheme();
14
+ useTyping(focused);
15
+ const box = useRef(null);
16
+ useMouseTarget(box, { onPress: () => onFocus?.() });
17
+ // Keys faster than renders chain on `pending`, not on the value captured at the last render.
18
+ const pending = useRef(value);
19
+ pending.current = value;
20
+ const emit = (next) => {
21
+ pending.current = next;
22
+ onChange?.(next);
23
+ };
24
+ useKeys([
25
+ { keys: ['enter'], run: () => onSubmit?.(pending.current) },
26
+ { keys: ['backspace', 'delete'], run: () => emit(pending.current.slice(0, -1)) },
27
+ ], { isActive: focused, onText: text => emit(pending.current + text) });
28
+ const cursor = focused ? '█' : '';
29
+ const bar = focused ? '┃' : ' ';
30
+ return (_jsxs(Box, { ref: box, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: 1, backgroundColor: theme.tokens.surface, children: [_jsx(Text, { color: theme.tokens.accentSecondary, children: bar }), _jsx(Text, { color: theme.tokens.accentSecondary, children: bar }), _jsx(Text, { color: theme.tokens.accentSecondary, children: bar }), footer && _jsx(Text, { color: theme.tokens.accentSecondary, children: bar })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, backgroundColor: theme.tokens.surface, paddingX: 2, children: [_jsx(Text, { children: " " }), _jsxs(Text, { color: value ? theme.tokens.text : theme.tokens.textMuted, wrap: "truncate-end", children: [value || placeholder, cursor] }), _jsx(Text, { children: " " }), footer] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: focused ? theme.tokens.accentSecondary : theme.tokens.surface, children: focused ? '╹' : '▀' }), _jsx(Box, { flexGrow: 1, overflow: "hidden", height: 1, children: _jsx(Text, { color: theme.tokens.surface, wrap: "wrap", children: '▀'.repeat(400) }) })] })] }));
31
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ type Props = {
3
+ title: string;
4
+ children?: ReactNode;
5
+ };
6
+ /**
7
+ * A page's introduction: a block on the panel surface with no header bar, at the top of the page
8
+ * before its sections, holding one bright title line and a short muted explanation. It is not a
9
+ * section (nothing is listed or edited in it), so it has no header bar; at most one per page.
10
+ */
11
+ export declare function Intro({ title, children }: Props): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from '../theme/ThemeContext.js';
4
+ import { SurfaceProvider } from './FocusRegion.js';
5
+ /**
6
+ * A page's introduction: a block on the panel surface with no header bar, at the top of the page
7
+ * before its sections, holding one bright title line and a short muted explanation. It is not a
8
+ * section (nothing is listed or edited in it), so it has no header bar; at most one per page.
9
+ */
10
+ export function Intro({ title, children }) {
11
+ const theme = useTheme();
12
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, backgroundColor: theme.tokens.panel, paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: theme.tokens.textBright, children: title }), _jsx(SurfaceProvider, { surface: "panel", children: children })] }));
13
+ }
@@ -0,0 +1,13 @@
1
+ export type KeyHint = {
2
+ readonly key: string;
3
+ readonly label: string; /** What a click on the pair runs; the App's built-in hints have none and are routed by key. */
4
+ readonly run?: () => void;
5
+ };
6
+ type Props = {
7
+ hints: readonly KeyHint[];
8
+ left?: string;
9
+ onPress?: (hint: KeyHint) => void;
10
+ };
11
+ /** Bottom line: muted context on the left, "key (bright) + label (muted)" pairs on the right. A click on a pair triggers it. */
12
+ export declare function KeyHintBar({ hints, left, onPress }: Props): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useRef } from 'react';
4
+ import { useMouseTarget } from '../input/Mouse.js';
5
+ import { TextWidth } from '../theme/TextWidth.js';
6
+ import { useTheme } from '../theme/ThemeContext.js';
7
+ /** Bottom line: muted context on the left, "key (bright) + label (muted)" pairs on the right. A click on a pair triggers it. */
8
+ export function KeyHintBar({ hints, left = '', onPress }) {
9
+ const theme = useTheme();
10
+ const pairs = useRef(null);
11
+ const widths = hints.map(hint => 2 + TextWidth.of(hint.key) + 1 + TextWidth.of(hint.label));
12
+ useMouseTarget(pairs, {
13
+ onPress: event => {
14
+ let x = event.localX;
15
+ for (const [index, width] of widths.entries()) {
16
+ if (x < width)
17
+ return onPress?.(hints[index]);
18
+ x -= width;
19
+ }
20
+ },
21
+ });
22
+ return (_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, width: "100%", children: [_jsx(Box, { flexGrow: 1, flexBasis: 0, overflow: "hidden", children: _jsx(Text, { color: theme.tokens.textMuted, wrap: "truncate-middle", children: left }) }), _jsx(Box, { ref: pairs, flexShrink: 0, flexDirection: "row", children: hints.map(hint => (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { color: theme.tokens.text, children: [hint.key, " "] }), _jsx(Text, { color: theme.tokens.textMuted, children: hint.label })] }, hint.key))) })] }));
23
+ }
@@ -0,0 +1,11 @@
1
+ import { type TextProps } from 'ink';
2
+ import type { ReactNode } from 'react';
3
+ export type LabelVariant = 'default' | 'muted' | 'bright' | 'heading' | 'accent' | 'success' | 'warning' | 'error';
4
+ type Props = {
5
+ variant?: LabelVariant;
6
+ children: ReactNode;
7
+ wrap?: TextProps['wrap'];
8
+ };
9
+ /** Text in one of the theme's semantic roles. Headings are bold. */
10
+ export declare function Label({ variant, children, wrap }: Props): import("react").JSX.Element;
11
+ export {};