@voidlabs.gg/voidshift 0.0.0-stage → 0.4.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 (89) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +134 -2
  3. package/dist/checks.d.ts +9 -0
  4. package/dist/checks.js +51 -0
  5. package/dist/commands/args.d.ts +13 -0
  6. package/dist/commands/args.js +22 -0
  7. package/dist/commands/config.d.ts +1 -0
  8. package/dist/commands/config.js +181 -0
  9. package/dist/commands/doctor.d.ts +3 -0
  10. package/dist/commands/doctor.js +52 -0
  11. package/dist/commands/help.d.ts +3 -0
  12. package/dist/commands/help.js +59 -0
  13. package/dist/commands/setup.d.ts +2 -0
  14. package/dist/commands/setup.js +135 -0
  15. package/dist/commands/status.d.ts +2 -0
  16. package/dist/commands/status.js +60 -0
  17. package/dist/commands/tools.d.ts +9 -0
  18. package/dist/commands/tools.js +111 -0
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/config.d.ts +9 -0
  22. package/dist/config.js +29 -0
  23. package/dist/dotted.d.ts +9 -0
  24. package/dist/dotted.js +86 -0
  25. package/dist/loader.d.ts +6 -0
  26. package/dist/loader.js +51 -0
  27. package/dist/main.d.ts +2 -0
  28. package/dist/main.js +93 -0
  29. package/dist/registry.d.ts +8 -0
  30. package/dist/registry.js +26 -0
  31. package/dist/tool.d.ts +51 -0
  32. package/dist/tool.js +4 -0
  33. package/dist/tui/actions.d.ts +17 -0
  34. package/dist/tui/actions.js +99 -0
  35. package/dist/tui/app.d.ts +6 -0
  36. package/dist/tui/app.js +246 -0
  37. package/dist/tui/context.d.ts +62 -0
  38. package/dist/tui/context.js +48 -0
  39. package/dist/tui/data.d.ts +196 -0
  40. package/dist/tui/data.js +302 -0
  41. package/dist/tui/format.d.ts +11 -0
  42. package/dist/tui/format.js +54 -0
  43. package/dist/tui/home.d.ts +51 -0
  44. package/dist/tui/home.js +336 -0
  45. package/dist/tui/keys.d.ts +2 -0
  46. package/dist/tui/keys.js +107 -0
  47. package/dist/tui/nav.d.ts +24 -0
  48. package/dist/tui/nav.js +32 -0
  49. package/dist/tui/palette.d.ts +32 -0
  50. package/dist/tui/palette.js +269 -0
  51. package/dist/tui/run.d.ts +1 -0
  52. package/dist/tui/run.js +20 -0
  53. package/dist/tui/screens/agents.d.ts +6 -0
  54. package/dist/tui/screens/agents.js +44 -0
  55. package/dist/tui/screens/awake.d.ts +23 -0
  56. package/dist/tui/screens/awake.js +209 -0
  57. package/dist/tui/screens/checks.d.ts +11 -0
  58. package/dist/tui/screens/checks.js +106 -0
  59. package/dist/tui/screens/homes.d.ts +14 -0
  60. package/dist/tui/screens/homes.js +99 -0
  61. package/dist/tui/screens/list-window.d.ts +6 -0
  62. package/dist/tui/screens/list-window.js +18 -0
  63. package/dist/tui/screens/log.d.ts +26 -0
  64. package/dist/tui/screens/log.js +266 -0
  65. package/dist/tui/screens/memory.d.ts +24 -0
  66. package/dist/tui/screens/memory.js +174 -0
  67. package/dist/tui/screens/project-form.d.ts +14 -0
  68. package/dist/tui/screens/project-form.js +137 -0
  69. package/dist/tui/screens/projects.d.ts +17 -0
  70. package/dist/tui/screens/projects.js +109 -0
  71. package/dist/tui/screens/runs.d.ts +43 -0
  72. package/dist/tui/screens/runs.js +420 -0
  73. package/dist/tui/screens/settings.d.ts +17 -0
  74. package/dist/tui/screens/settings.js +153 -0
  75. package/dist/tui/screens/workflow-form.d.ts +58 -0
  76. package/dist/tui/screens/workflow-form.js +375 -0
  77. package/dist/tui/screens/workflows.d.ts +41 -0
  78. package/dist/tui/screens/workflows.js +174 -0
  79. package/dist/tui/screens.d.ts +30 -0
  80. package/dist/tui/screens.js +172 -0
  81. package/dist/tui/theme.d.ts +14 -0
  82. package/dist/tui/theme.js +18 -0
  83. package/dist/tui/widgets.d.ts +122 -0
  84. package/dist/tui/widgets.js +264 -0
  85. package/dist/ui.d.ts +5 -0
  86. package/dist/ui.js +82 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.js +17 -0
  89. package/package.json +59 -4
@@ -0,0 +1,172 @@
1
+ // The screen registry and the contract every screen follows.
2
+ //
3
+ // ADDING A SCREEN (one import plus one registry line)
4
+ // 1. Write your screen in its own file, e.g. src/tui/screens/runs.ts, and
5
+ // export a Screen: `export const runsScreen: Screen = { id: 'agents.runs', ... }`.
6
+ // 2. Import it below and add its name to `registry`, after the placeholders.
7
+ // A later entry with the same id replaces the placeholder.
8
+ //
9
+ // A SCREEN
10
+ // id unique, e.g. "agents.log". Ids below are fixed so Home can open them.
11
+ // title breadcrumb crumb(s) for the header, a string, string[] or a
12
+ // function of the route params: (p) => ['Workflows', `Edit ${p.name}`].
13
+ // Screens under the top one contribute only their first crumb.
14
+ // component a function component taking { params }. Everything else comes
15
+ // from hooks, never from props.
16
+ // hints (props) => Hint[]: the default footer key hints. Use the
17
+ // useHints() hook instead when they depend on component state.
18
+ // End every list with { key: 'esc', label: 'back' }.
19
+ //
20
+ // WHAT A SCREEN GETS (all from ./context.ts, call them inside the component)
21
+ // useScreen() the ScreenContext:
22
+ // params the object passed to nav.push(id, params)
23
+ // nav push(id, params) / pop() / replace(id, params)
24
+ // actions run(spec): confirm dialog, then `vsf <args>` as a child
25
+ // process, toast, store refresh. confirm(opts). detach(args,
26
+ // logFile) for long runs. terminal(args) for commands that
27
+ // need the real terminal. NEVER do a write or a blocking
28
+ // command in-process; add --yes to args only for commands
29
+ // that take it (clean, workflow rm, project rm).
30
+ // toast(text, kind) 'ok' | 'error' | 'info'
31
+ // columns, rows terminal size
32
+ // useData() (./data.ts) the live store: state.agents, state.runs,
33
+ // state.workflows, state.projects, state.homes, state.awake,
34
+ // state.memory, state.checks, state.steps (the hidden
35
+ // `vsf agents steps` catalog), state.errors[source],
36
+ // readConfig(tool), refresh(). Polled every 2 seconds.
37
+ // useKeys(fn) key handler, silent while a dialog or the palette is open
38
+ // and while a text field has focus.
39
+ // useHints(list) dynamic footer hints.
40
+ // useEquivalent(args) the "= vsf ..." command shown bottom right for forms
41
+ // and actions, e.g. useEquivalent(['agents', 'cancel', card]).
42
+ // useCapture(on) TextInput and Select call this for you. While a field has
43
+ // focus the app ignores q : ? and Esc, so a form must handle
44
+ // Esc itself with useKeys(fn, { whileCapturing: true }).
45
+ // useBackGuard(fn) Esc asks this first when no field has focus; return true
46
+ // if you used the key (e.g. to clear a filter).
47
+ //
48
+ // WIDGETS (./widgets.ts): Panel, KeyHints, Table, SubTabs, ProgressSteps,
49
+ // Badge, Confirm, TextInput, Select, Lines/SplitRow for coloured text.
50
+ //
51
+ // SUB-TABS. "agents" is a shell: a tab bar over the screens agents.runs,
52
+ // agents.workflows, agents.projects, agents.homes and settings. Left and right
53
+ // switch tabs. A tab is an ordinary screen; it is rendered inside the shell and
54
+ // receives the shell's params plus (for settings) { tool: 'agents' }. Push
55
+ // "agents.log" { card, step? } for a run's log, "agents.workflow.form" { name? }
56
+ // (no name means new) and "agents.project.form" { name? } on top of the shell.
57
+ import React from 'react';
58
+ import { Box, Text, useInput } from 'ink';
59
+ import { ScreenCtx, useScreen } from "./context.js";
60
+ import { homeScreen } from "./home.js";
61
+ import { agentsScreen as agentsShellScreen } from "./screens/agents.js";
62
+ import { runsScreen } from "./screens/runs.js";
63
+ import { logScreen } from "./screens/log.js";
64
+ import { settingsScreen } from "./screens/settings.js";
65
+ import { awakeScreen } from "./screens/awake.js";
66
+ import { memoryScreen } from "./screens/memory.js";
67
+ import { checksScreen } from "./screens/checks.js";
68
+ import { workflowsScreen } from "./screens/workflows.js";
69
+ import { workflowFormScreen } from "./screens/workflow-form.js";
70
+ import { projectsScreen } from "./screens/projects.js";
71
+ import { projectFormScreen } from "./screens/project-form.js";
72
+ import { homesScreen } from "./screens/homes.js";
73
+ import { SubTabs } from "./widgets.js";
74
+ import { theme } from "./theme.js";
75
+ const h = React.createElement;
76
+ const BACK = { key: 'esc', label: 'back' };
77
+ // ---- placeholders ---------------------------------------------------------
78
+ function placeholder(id, title, extra = {}) {
79
+ return {
80
+ id,
81
+ title,
82
+ component: (props) => h(Box, { flexDirection: 'column', paddingX: 1 }, h(Text, { bold: true, color: theme.bold }, titleOf({ title }, props.params).join(' › ')), h(Text, { color: theme.dim }, 'Coming in this release.')),
83
+ hints: () => [BACK],
84
+ ...extra,
85
+ };
86
+ }
87
+ export const AGENT_TABS = [
88
+ { id: 'agents.runs', label: 'Runs' },
89
+ { id: 'agents.workflows', label: 'Workflows' },
90
+ { id: 'agents.projects', label: 'Projects' },
91
+ { id: 'agents.homes', label: 'Homes' },
92
+ { id: 'settings', label: 'Settings', params: { tool: 'agents' } },
93
+ ];
94
+ const TAB_HINT = { key: '←→', label: 'tab' };
95
+ export const activeTab = (params) => AGENT_TABS.find((t) => t.id === params.tab) ?? AGENT_TABS[0];
96
+ // Renders another registered screen inside the current one with its own
97
+ // params. Hints it sets get `extra` appended.
98
+ export function ScreenHost(props) {
99
+ const parent = useScreen();
100
+ const screen = screens[props.id];
101
+ if (!screen)
102
+ return h(Text, { color: theme.red }, `Unknown screen ${props.id}`);
103
+ const ctx = {
104
+ ...parent,
105
+ id: props.id,
106
+ params: props.params,
107
+ setHints: (hints) => parent.setHints(hints && props.extraHints ? [...hints, ...props.extraHints] : hints),
108
+ };
109
+ return h(ScreenCtx.Provider, { value: ctx }, h(screen.component, { params: props.params }));
110
+ }
111
+ function AgentsShell(props) {
112
+ const ctx = useScreen();
113
+ const tab = activeTab(props.params);
114
+ useInput((_input, key) => {
115
+ const i = AGENT_TABS.indexOf(tab);
116
+ const next = key.rightArrow ? AGENT_TABS[(i + 1) % AGENT_TABS.length] : key.leftArrow ? AGENT_TABS[(i + AGENT_TABS.length - 1) % AGENT_TABS.length] : undefined;
117
+ if (next)
118
+ ctx.nav.replace('agents', { ...props.params, tab: next.id });
119
+ }, { isActive: !ctx.modalOpen && !ctx.capturing });
120
+ return h(Box, { flexDirection: 'column' }, h(SubTabs, { tabs: AGENT_TABS.map((t) => ({ id: t.id, label: t.label })), active: tab.id }), h(Text, null, ' '), h(ScreenHost, { key: tab.id, id: tab.id, params: { ...props.params, ...tab.params }, extraHints: [TAB_HINT] }));
121
+ }
122
+ const agentsScreen = {
123
+ id: 'agents',
124
+ title: (p) => ['Agents', activeTab(p).label],
125
+ component: AgentsShell,
126
+ hints: (p) => {
127
+ const tab = activeTab(p);
128
+ const inner = screens[tab.id];
129
+ return [...(inner ? inner.hints({ params: { ...p, ...tab.params } }) : [BACK]), TAB_HINT];
130
+ },
131
+ };
132
+ // ---- the registry ---------------------------------------------------------
133
+ // Add screens here. Later entries replace earlier ones with the same id.
134
+ const registry = [
135
+ homeScreen,
136
+ agentsScreen,
137
+ placeholder('agents.runs', 'Runs'),
138
+ placeholder('agents.workflows', 'Workflows'),
139
+ placeholder('agents.projects', 'Projects'),
140
+ placeholder('agents.homes', 'Homes'),
141
+ placeholder('settings', 'Settings'),
142
+ placeholder('agents.log', 'Log'),
143
+ placeholder('agents.workflow.form', 'Workflow'),
144
+ placeholder('agents.project.form', 'Project'),
145
+ placeholder('awake', 'Awake'),
146
+ placeholder('memory', 'Memory'),
147
+ placeholder('checks', 'Checks'),
148
+ agentsShellScreen,
149
+ runsScreen,
150
+ logScreen,
151
+ settingsScreen,
152
+ awakeScreen,
153
+ memoryScreen,
154
+ checksScreen,
155
+ workflowsScreen,
156
+ workflowFormScreen,
157
+ projectsScreen,
158
+ projectFormScreen,
159
+ homesScreen,
160
+ ];
161
+ export const screens = Object.fromEntries(registry.map((s) => [s.id, s]));
162
+ export function titleOf(screen, params) {
163
+ const t = typeof screen.title === 'function' ? screen.title(params) : screen.title;
164
+ return Array.isArray(t) ? t : [t];
165
+ }
166
+ export function hintsOf(id, params) {
167
+ return screens[id]?.hints({ params }) ?? [BACK];
168
+ }
169
+ export function crumbsOf(id, params) {
170
+ const s = screens[id];
171
+ return s ? titleOf(s, params) : [id];
172
+ }
@@ -0,0 +1,14 @@
1
+ export declare const theme: {
2
+ readonly dim: "#6b7280";
3
+ readonly accent: "#7aa2f7";
4
+ readonly green: "#9ece6a";
5
+ readonly yellow: "#e0af68";
6
+ readonly red: "#f7768e";
7
+ readonly text: "#d7dae0";
8
+ readonly bold: "#ffffff";
9
+ readonly selected: "#1f2335";
10
+ readonly keycap: "#565f89";
11
+ readonly keycapText: "#0f1117";
12
+ readonly onAccent: "#0f1117";
13
+ };
14
+ export type Colour = string;
@@ -0,0 +1,18 @@
1
+ // Colours for the dashboard. Hex values work on every modern terminal and
2
+ // match the approved mockup: dim grey, one blue accent, and three signals.
3
+ export const theme = {
4
+ dim: '#6b7280',
5
+ accent: '#7aa2f7',
6
+ green: '#9ece6a',
7
+ yellow: '#e0af68',
8
+ red: '#f7768e',
9
+ text: '#d7dae0',
10
+ bold: '#ffffff',
11
+ // Background of the selected row.
12
+ selected: '#1f2335',
13
+ // Background of a key cap in the hints footer.
14
+ keycap: '#565f89',
15
+ keycapText: '#0f1117',
16
+ // Text on an accent background (the active tab, the chosen button).
17
+ onAccent: '#0f1117',
18
+ };
@@ -0,0 +1,122 @@
1
+ import React from 'react';
2
+ import { type Key } from 'ink';
3
+ import { type Hint } from './context.ts';
4
+ export interface PanelProps {
5
+ title: string;
6
+ focused?: boolean;
7
+ colour?: string;
8
+ width?: number | string;
9
+ height?: number;
10
+ flexGrow?: number;
11
+ subtitle?: string;
12
+ children?: React.ReactNode;
13
+ }
14
+ export declare function Panel(props: PanelProps): React.ReactElement;
15
+ export interface Seg {
16
+ text: string;
17
+ colour?: string;
18
+ bold?: boolean;
19
+ dim?: boolean;
20
+ bg?: string;
21
+ }
22
+ export type Line = Seg[];
23
+ export declare const seg: (text: string, colour?: string, extra?: Partial<Seg>) => Seg;
24
+ export declare function Lines(props: {
25
+ lines: Line[];
26
+ }): React.ReactElement;
27
+ export declare function SplitRow(props: {
28
+ left: Line;
29
+ right?: Line;
30
+ }): React.ReactElement;
31
+ export interface FooterLayout {
32
+ rows: Hint[][];
33
+ command: string | null;
34
+ inline: boolean;
35
+ }
36
+ export declare function layoutFooter(hints: Hint[], command: string | undefined, width: number): FooterLayout;
37
+ export declare function KeyHints(props: {
38
+ hints: Hint[];
39
+ right?: string;
40
+ width?: number;
41
+ }): React.ReactElement;
42
+ export type Cell = string | {
43
+ text: string;
44
+ colour?: string;
45
+ bold?: boolean;
46
+ };
47
+ export interface Column {
48
+ title: string;
49
+ width?: number;
50
+ }
51
+ export declare function fitCell(text: string, width: number): string;
52
+ export declare function fitColumn(text: string, width: number): string;
53
+ export declare function Table(props: {
54
+ columns: Column[];
55
+ rows: Cell[][];
56
+ selected?: number;
57
+ empty?: string;
58
+ marker?: boolean;
59
+ }): React.ReactElement;
60
+ export declare function SubTabs(props: {
61
+ tabs: {
62
+ id: string;
63
+ label: string;
64
+ }[];
65
+ active: string;
66
+ }): React.ReactElement;
67
+ export type StepState = 'done' | 'running' | 'pending' | 'failed';
68
+ export interface StepProgress {
69
+ label: string;
70
+ state: StepState;
71
+ fraction?: number;
72
+ }
73
+ export declare function barParts(state: StepState, fraction: number, width: number): {
74
+ filled: string;
75
+ head: string;
76
+ rest: string;
77
+ };
78
+ export declare function ProgressSteps(props: {
79
+ steps: StepProgress[];
80
+ barWidth?: number;
81
+ }): React.ReactElement;
82
+ export declare function outcomeColour(outcome: string): string;
83
+ export declare function Badge(props: {
84
+ outcome: string;
85
+ label?: string;
86
+ }): React.ReactElement;
87
+ export interface ConfirmProps {
88
+ title: string;
89
+ body?: string;
90
+ confirmLabel?: string;
91
+ cancelLabel?: string;
92
+ onConfirm(): void;
93
+ onCancel(): void;
94
+ }
95
+ export declare function Confirm(props: ConfirmProps): React.ReactElement;
96
+ export interface EditResult {
97
+ value: string;
98
+ cursor: number;
99
+ }
100
+ export declare function editText(value: string, cursor: number, input: string, key: Partial<Key>): EditResult;
101
+ export interface TextInputProps {
102
+ value: string;
103
+ onChange(value: string): void;
104
+ focus?: boolean;
105
+ placeholder?: string;
106
+ mask?: boolean;
107
+ onSubmit?(value: string): void;
108
+ }
109
+ export declare function TextInput(props: TextInputProps): React.ReactElement;
110
+ export interface Option {
111
+ value: string;
112
+ label: string;
113
+ hint?: string;
114
+ }
115
+ export declare function cycle(count: number, index: number, delta: number): number;
116
+ export interface SelectProps {
117
+ options: Option[];
118
+ value: string;
119
+ onChange(value: string): void;
120
+ focus?: boolean;
121
+ }
122
+ export declare function Select(props: SelectProps): React.ReactElement;
@@ -0,0 +1,264 @@
1
+ // Reusable pieces for every screen: a titled box, the key hints footer, a
2
+ // table, step progress, outcome badges, a confirm dialog, and the two form
3
+ // fields (TextInput and Select). Elements are built with React.createElement
4
+ // because Node's type stripping does not run JSX.
5
+ import React, { useEffect, useState } from 'react';
6
+ import { Box, Text, useInput, useWindowSize } from 'ink';
7
+ import { useCapture, useScreen } from "./context.js";
8
+ import { theme } from "./theme.js";
9
+ const h = React.createElement;
10
+ export function Panel(props) {
11
+ const colour = props.colour ?? (props.focused ? theme.accent : theme.dim);
12
+ const top = h(Box, { flexDirection: 'row', flexShrink: 0, height: 1 }, h(Box, { flexShrink: 0 }, h(Text, { wrap: 'truncate-end' }, h(Text, { color: colour }, '╭─ '), h(Text, { bold: true, color: theme.bold }, props.title), props.subtitle ? h(Text, { color: theme.dim }, ` ${props.subtitle}`) : null, h(Text, { color: colour }, ' '))),
13
+ // A rule far longer than any terminal, clipped to whatever width is left.
14
+ h(Box, { flexGrow: 1, flexBasis: 0, height: 1, overflow: 'hidden' }, h(Text, { color: colour }, '─'.repeat(400))), h(Box, { flexShrink: 0 }, h(Text, { color: colour }, '╮')));
15
+ const body = h(Box, { flexDirection: 'column', borderStyle: 'round', borderTop: false, borderColor: colour, paddingX: 1, flexGrow: 1 }, props.children);
16
+ return h(Box, { flexDirection: 'column', width: props.width, height: props.height, flexGrow: props.flexGrow }, top, body);
17
+ }
18
+ export const seg = (text, colour, extra = {}) => ({ text, colour, ...extra });
19
+ const segNodes = (line) => line.map((s, j) => h(Text, { key: j, color: s.colour, bold: s.bold, dimColor: s.dim, backgroundColor: s.bg }, s.text));
20
+ export function Lines(props) {
21
+ return h(Box, { flexDirection: 'column' }, ...props.lines.map((line, i) => h(Text, { key: i, wrap: 'truncate-end' }, ...(line.length ? segNodes(line) : [' ']))));
22
+ }
23
+ // A row with text on the left and text pushed to the right edge.
24
+ export function SplitRow(props) {
25
+ return h(Box, { flexDirection: 'row' }, h(Box, { flexShrink: 1, flexGrow: 1 }, h(Text, { wrap: 'truncate-end' }, ...segNodes(props.left))), props.right?.length ? h(Box, { flexShrink: 0, marginLeft: 2 }, h(Text, null, ...segNodes(props.right))) : null);
26
+ }
27
+ // ---- KeyHints footer -----------------------------------------------------
28
+ // Width of one hint as drawn: " key " + " label ".
29
+ const hintWidth = (hint) => hint.key.length + hint.label.length + 5;
30
+ // Lays out the footer for `width` columns (inside the padding). Hints and the
31
+ // command share one line when they fit. Otherwise the hints wrap onto as many
32
+ // lines as they need and the command gets its own line below, cut with an
33
+ // ellipsis only if it is longer than the width. Hints are never cut.
34
+ export function layoutFooter(hints, command, width) {
35
+ const w = Math.max(10, width);
36
+ const total = hints.reduce((n, hint) => n + hintWidth(hint), 0) - 2;
37
+ if (!command && total + 2 <= w)
38
+ return { rows: [hints], command: null, inline: false };
39
+ if (command && total + 2 + command.length <= w)
40
+ return { rows: [hints], command, inline: true };
41
+ const rows = [];
42
+ let row = [];
43
+ let used = 0;
44
+ for (const hint of hints) {
45
+ const hw = hintWidth(hint);
46
+ if (row.length && used + hw > w) {
47
+ rows.push(row);
48
+ row = [];
49
+ used = 0;
50
+ }
51
+ row.push(hint);
52
+ used += hw;
53
+ }
54
+ if (row.length)
55
+ rows.push(row);
56
+ if (!command)
57
+ return { rows, command: null, inline: false };
58
+ return { rows, command: command.length > w ? command.slice(0, w - 1) + '…' : command, inline: false };
59
+ }
60
+ export function KeyHints(props) {
61
+ const win = useWindowSize();
62
+ const layout = layoutFooter(props.hints, props.right, (props.width ?? win.columns) - 2);
63
+ const rowNode = (row, r) => h(Text, { key: r, wrap: 'truncate-end' }, ...row.flatMap((hint, i) => [
64
+ h(Text, { key: `k${i}`, backgroundColor: theme.keycap, color: theme.keycapText }, ` ${hint.key} `),
65
+ h(Text, { key: `l${i}`, color: theme.text }, ` ${hint.label} `),
66
+ ]));
67
+ if (layout.inline) {
68
+ return h(Box, { flexDirection: 'row', paddingX: 1 }, h(Box, { flexGrow: 1, flexShrink: 1 }, rowNode(layout.rows[0], 0)), h(Box, { flexShrink: 0, marginLeft: 2 }, h(Text, { color: theme.dim }, layout.command)));
69
+ }
70
+ return h(Box, { flexDirection: 'column', paddingX: 1 }, ...layout.rows.map(rowNode), layout.command ? h(Text, { key: 'cmd', color: theme.dim, wrap: 'truncate-end' }, layout.command) : null);
71
+ }
72
+ const cellText = (c) => (typeof c === 'string' ? c : c.text);
73
+ const cellColour = (c) => (typeof c === 'string' ? undefined : c.colour);
74
+ // Cuts or pads a cell to exactly `width` characters.
75
+ export function fitCell(text, width) {
76
+ const t = text.length > width ? text.slice(0, Math.max(0, width - 1)) + '…' : text;
77
+ return t.padEnd(width);
78
+ }
79
+ // A table cell in a column `width` wide: the text is cut so at least two spaces
80
+ // are left before the next column, then padded to the full width.
81
+ export function fitColumn(text, width) {
82
+ return fitCell(text, Math.max(1, width - 2)).padEnd(width);
83
+ }
84
+ export function Table(props) {
85
+ const marker = props.marker !== false;
86
+ const line = (cells, i, isHead) => {
87
+ const sel = !isHead && i === props.selected;
88
+ const bg = sel ? theme.selected : undefined;
89
+ return h(Box, { key: isHead ? 'head' : i, flexDirection: 'row' }, h(Text, { color: theme.accent, backgroundColor: bg }, marker ? (sel ? '▶ ' : ' ') : ''), ...props.columns.map((col, j) => {
90
+ const cell = cells[j] ?? '';
91
+ const text = col.width ? fitColumn(cellText(cell), col.width) : cellText(cell);
92
+ const bold = sel || (typeof cell !== 'string' && cell.bold);
93
+ const node = h(Text, { wrap: 'truncate-end', color: isHead ? theme.dim : cellColour(cell), bold, backgroundColor: bg }, text);
94
+ return col.width ? h(Box, { key: j, width: col.width, flexShrink: 0 }, node) : h(Box, { key: j, flexGrow: 1, flexShrink: 1 }, node);
95
+ }));
96
+ };
97
+ return h(Box, { flexDirection: 'column' }, line(props.columns.map((c) => c.title), -1, true), ...props.rows.map((r, i) => line(r, i, false)), props.rows.length === 0 && props.empty ? h(Text, { color: theme.dim }, props.empty) : null);
98
+ }
99
+ // ---- Sub-tabs -------------------------------------------------------------
100
+ export function SubTabs(props) {
101
+ const parts = [];
102
+ props.tabs.forEach((t, i) => {
103
+ const on = t.id === props.active;
104
+ parts.push(on
105
+ ? h(Text, { key: i, backgroundColor: theme.accent, color: theme.onAccent }, ` ${t.label} `)
106
+ : h(Text, { key: i, color: theme.text }, ` ${t.label} `));
107
+ parts.push(h(Text, { key: `s${i}` }, ' '));
108
+ });
109
+ return h(Box, { paddingLeft: 1 }, h(Text, null, ...parts));
110
+ }
111
+ // The three parts of a bar: filled, the head, the rest. Done is all filled.
112
+ export function barParts(state, fraction, width) {
113
+ if (state === 'done' || state === 'failed')
114
+ return { filled: '━'.repeat(width), head: '', rest: '' };
115
+ if (state === 'pending')
116
+ return { filled: '', head: '', rest: '─'.repeat(width) };
117
+ const n = Math.max(0, Math.min(width - 1, Math.round(Math.max(0, Math.min(1, fraction)) * (width - 1))));
118
+ return { filled: '━'.repeat(n), head: '╸', rest: '─'.repeat(width - n - 1) };
119
+ }
120
+ // "Dev ━━━━━━━━━━━━ ✓ QA ━━━━━━╸──────"
121
+ export function ProgressSteps(props) {
122
+ const width = props.barWidth ?? 12;
123
+ const nodes = [];
124
+ props.steps.forEach((s, i) => {
125
+ const colour = s.state === 'done' ? theme.green : s.state === 'failed' ? theme.red : s.state === 'running' ? theme.accent : theme.dim;
126
+ const bar = barParts(s.state, s.fraction ?? 0.5, width);
127
+ nodes.push(h(Text, { key: i }, h(Text, { color: colour }, `${s.label} ${bar.filled}${bar.head}`), h(Text, { color: theme.dim }, bar.rest), s.state === 'done' ? h(Text, { color: theme.green }, ' ✓') : s.state === 'failed' ? h(Text, { color: theme.red }, ' ✗') : null, i < props.steps.length - 1 ? h(Text, null, ' ') : null));
128
+ });
129
+ return h(Text, null, ...nodes);
130
+ }
131
+ // ---- Badge ---------------------------------------------------------------
132
+ export function outcomeColour(outcome) {
133
+ switch (outcome) {
134
+ case 'verified':
135
+ return theme.green;
136
+ case 'parked':
137
+ case 'changes':
138
+ return theme.yellow;
139
+ case 'cancelled':
140
+ return theme.red;
141
+ case 'running':
142
+ return theme.accent;
143
+ default:
144
+ return theme.dim;
145
+ }
146
+ }
147
+ export function Badge(props) {
148
+ return h(Text, { color: outcomeColour(props.outcome) }, props.label ?? props.outcome);
149
+ }
150
+ // Left and right choose, enter confirms, esc cancels, y and n are shortcuts.
151
+ export function Confirm(props) {
152
+ const [yes, setYes] = useState(true);
153
+ useInput((input, key) => {
154
+ if (key.leftArrow || key.rightArrow || key.tab)
155
+ setYes((v) => !v);
156
+ else if (key.return)
157
+ (yes ? props.onConfirm : props.onCancel)();
158
+ else if (key.escape || input === 'n')
159
+ props.onCancel();
160
+ else if (input === 'y')
161
+ props.onConfirm();
162
+ });
163
+ const button = (label, on) => on ? h(Text, { backgroundColor: theme.accent, color: theme.onAccent }, ` ${label} `) : h(Text, { color: theme.text }, ` ${label} `);
164
+ return h(Box, { justifyContent: 'center', marginTop: 2 }, h(Box, { flexDirection: 'column', borderStyle: 'round', borderColor: theme.accent, paddingX: 2, width: 66 }, h(Text, { bold: true, color: theme.bold }, props.title), props.body ? h(Box, { marginTop: 1 }, h(Text, null, props.body)) : null, h(Box, { marginTop: 1, justifyContent: 'center', gap: 3 }, button(props.confirmLabel ?? 'Yes', yes), button(props.cancelLabel ?? 'No', !yes))));
165
+ }
166
+ // Applies one keypress to a single-line text value. Pure. Keys it does not
167
+ // own (enter, tab, esc, up, down) leave the value unchanged for the form to use.
168
+ export function editText(value, cursor, input, key) {
169
+ const at = Math.max(0, Math.min(cursor, value.length));
170
+ if (key.leftArrow)
171
+ return { value, cursor: Math.max(0, at - 1) };
172
+ if (key.rightArrow)
173
+ return { value, cursor: Math.min(value.length, at + 1) };
174
+ if (key.home || (key.ctrl && input === 'a'))
175
+ return { value, cursor: 0 };
176
+ if (key.end || (key.ctrl && input === 'e'))
177
+ return { value, cursor: value.length };
178
+ if (key.backspace || key.delete) {
179
+ if (at === 0)
180
+ return { value, cursor: at };
181
+ return { value: value.slice(0, at - 1) + value.slice(at), cursor: at - 1 };
182
+ }
183
+ if (key.ctrl && input === 'u')
184
+ return { value: value.slice(at), cursor: 0 };
185
+ if (key.ctrl && input === 'w') {
186
+ const head = value.slice(0, at).replace(/\S+\s*$/, '');
187
+ return { value: head + value.slice(at), cursor: head.length };
188
+ }
189
+ if (key.ctrl || key.meta || key.escape || key.return || key.tab || key.upArrow || key.downArrow || !input)
190
+ return { value, cursor: at };
191
+ // Pasted text arrives as one chunk; drop control characters and newlines.
192
+ const clean = input.replace(/[\u0000-\u001f\u007f]/g, '');
193
+ return { value: value.slice(0, at) + clean + value.slice(at), cursor: at + clean.length };
194
+ }
195
+ export function TextInput(props) {
196
+ const ctx = useScreen();
197
+ const [cursor, setCursor] = useState(props.value.length);
198
+ const focus = props.focus !== false;
199
+ useCapture(focus);
200
+ useInput((input, key) => {
201
+ if (key.return) {
202
+ props.onSubmit?.(props.value);
203
+ return;
204
+ }
205
+ const r = editText(props.value, cursor, input, key);
206
+ if (r.value !== props.value)
207
+ props.onChange(r.value);
208
+ setCursor(r.cursor);
209
+ }, { isActive: focus && !ctx.modalOpen });
210
+ const shown = props.mask ? '•'.repeat(props.value.length) : props.value;
211
+ const at = Math.min(cursor, shown.length);
212
+ if (!focus)
213
+ return h(Text, { color: shown ? undefined : theme.dim }, shown || props.placeholder || ' ');
214
+ const on = { backgroundColor: theme.accent, color: theme.onAccent };
215
+ return h(Text, { wrap: 'truncate-start' }, h(Text, on, ` ${shown.slice(0, at)}`), h(Text, { inverse: true }, shown[at] ?? ' '), h(Text, on, `${shown.slice(at + 1)} `));
216
+ }
217
+ // Next option index when moving by `delta`, wrapping around.
218
+ export function cycle(count, index, delta) {
219
+ return count === 0 ? 0 : (((index + delta) % count) + count) % count;
220
+ }
221
+ // Closed: "label ▾". Left and right change the value; enter opens the list,
222
+ // up and down move, enter picks, esc closes.
223
+ export function Select(props) {
224
+ const ctx = useScreen();
225
+ const [open, setOpen] = useState(false);
226
+ const idx = Math.max(0, props.options.findIndex((o) => o.value === props.value));
227
+ const [hover, setHover] = useState(idx);
228
+ const focus = props.focus !== false;
229
+ useCapture(open);
230
+ useEffect(() => {
231
+ if (!focus)
232
+ setOpen(false);
233
+ }, [focus]);
234
+ useInput((_input, key) => {
235
+ if (open) {
236
+ if (key.upArrow)
237
+ setHover((i) => cycle(props.options.length, i, -1));
238
+ else if (key.downArrow)
239
+ setHover((i) => cycle(props.options.length, i, 1));
240
+ else if (key.return) {
241
+ const o = props.options[hover];
242
+ if (o)
243
+ props.onChange(o.value);
244
+ setOpen(false);
245
+ }
246
+ else if (key.escape)
247
+ setOpen(false);
248
+ return;
249
+ }
250
+ if (key.return) {
251
+ setHover(idx);
252
+ setOpen(true);
253
+ }
254
+ else if (key.leftArrow)
255
+ props.onChange(props.options[cycle(props.options.length, idx, -1)]?.value ?? props.value);
256
+ else if (key.rightArrow)
257
+ props.onChange(props.options[cycle(props.options.length, idx, 1)]?.value ?? props.value);
258
+ }, { isActive: focus && !ctx.modalOpen });
259
+ const current = props.options[idx];
260
+ const head = h(Text, null, h(Text, { backgroundColor: focus ? theme.accent : undefined, color: focus ? theme.onAccent : undefined }, ` ${current?.label ?? props.value} `), current?.hint ? h(Text, { color: theme.dim }, ` ${current.hint}`) : null, h(Text, { color: theme.dim }, ' ▾'));
261
+ if (!open)
262
+ return head;
263
+ return h(Box, { flexDirection: 'column' }, head, ...props.options.map((o, i) => h(Text, { key: o.value, backgroundColor: i === hover ? theme.selected : undefined, color: i === hover ? theme.bold : theme.text }, `${i === hover ? '▶ ' : ' '}${o.label}`, o.hint ? h(Text, { color: theme.dim }, ` ${o.hint}`) : null)));
264
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Ui } from './tool.ts';
2
+ export declare class Cancelled extends Error {
3
+ }
4
+ export declare const ui: Ui;
5
+ export declare const defaultsUi: Ui;