@valyrianjs/terminal 0.1.0 → 0.1.2
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.
- package/README.md +105 -55
- package/dist/ansi.d.ts +20 -4
- package/dist/ansi.d.ts.map +1 -1
- package/dist/ansi.js +171 -47
- package/dist/ansi.js.map +1 -1
- package/dist/editor-state.d.ts +22 -0
- package/dist/editor-state.d.ts.map +1 -0
- package/dist/editor-state.js +110 -0
- package/dist/editor-state.js.map +1 -0
- package/dist/events.d.ts +1 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +15 -38
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/keymap.d.ts +7 -0
- package/dist/keymap.d.ts.map +1 -0
- package/dist/keymap.js +133 -0
- package/dist/keymap.js.map +1 -0
- package/dist/layout.d.ts +10 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +97 -7
- package/dist/layout.js.map +1 -1
- package/dist/mouse.d.ts +1 -0
- package/dist/mouse.d.ts.map +1 -1
- package/dist/mouse.js +24 -1
- package/dist/mouse.js.map +1 -1
- package/dist/output-writer.d.ts +9 -0
- package/dist/output-writer.d.ts.map +1 -0
- package/dist/output-writer.js +79 -0
- package/dist/output-writer.js.map +1 -0
- package/dist/paste.d.ts +7 -0
- package/dist/paste.d.ts.map +1 -0
- package/dist/paste.js +18 -0
- package/dist/paste.js.map +1 -0
- package/dist/primitives.d.ts +8 -1
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +9 -1
- package/dist/primitives.js.map +1 -1
- package/dist/render.d.ts +8 -3
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +840 -67
- package/dist/render.js.map +1 -1
- package/dist/runtime.d.ts +29 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +215 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +24 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +729 -199
- package/dist/session.js.map +1 -1
- package/dist/stream-log.d.ts +40 -0
- package/dist/stream-log.d.ts.map +1 -0
- package/dist/stream-log.js +73 -0
- package/dist/stream-log.js.map +1 -0
- package/dist/text.d.ts +3 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +19 -0
- package/dist/text.js.map +1 -0
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +254 -0
- package/dist/theme.js.map +1 -0
- package/dist/tree.d.ts +2 -0
- package/dist/tree.d.ts.map +1 -1
- package/dist/tree.js +42 -1
- package/dist/tree.js.map +1 -1
- package/dist/types.d.ts +183 -18
- package/dist/types.d.ts.map +1 -1
- package/docs/api-reference.md +302 -136
- package/docs/assets/quick-note.svg +13 -0
- package/docs/cookbook.md +297 -202
- package/docs/core-concepts.md +143 -55
- package/docs/getting-started.md +209 -90
- package/docs/interaction-model.md +95 -61
- package/docs/primitive-gallery.md +365 -0
- package/docs/session-runtime.md +132 -363
- package/docs/valyrian-modules.md +3196 -0
- package/llms-full.txt +5357 -0
- package/package.json +21 -8
- package/src/ansi.ts +269 -0
- package/src/clipboard.ts +76 -0
- package/src/editor-state.ts +162 -0
- package/src/events.ts +163 -0
- package/src/index.ts +92 -0
- package/src/keymap.ts +151 -0
- package/src/layout.ts +282 -0
- package/src/mouse.ts +68 -0
- package/src/output-writer.ts +93 -0
- package/src/paste.ts +23 -0
- package/src/primitives.ts +52 -0
- package/src/render.ts +1107 -0
- package/src/runtime.ts +273 -0
- package/src/scheduler.ts +33 -0
- package/src/session.ts +1260 -0
- package/src/stream-log.ts +96 -0
- package/src/text.ts +20 -0
- package/src/theme.ts +263 -0
- package/src/tree.ts +169 -0
- package/src/types.ts +523 -0
- package/tsconfig.json +4 -7
- package/docs/local-demo.md +0 -28
|
@@ -0,0 +1,3196 @@
|
|
|
1
|
+
# Valyrian.js modules in terminal apps
|
|
2
|
+
|
|
3
|
+
## Overview / quick picks
|
|
4
|
+
|
|
5
|
+
Terminal apps need app-layer modules for state, API clients, async tasks, forms, persistence, formatting, localization, and small data helpers. The terminal adapter owns terminal UI/input/output: rendering terminal nodes, focus, keyboard and mouse dispatch, output snapshots, stream cleanup, and host integration. Valyrian.js modules own app workflows: request execution, cache lifecycle, task state, form validation, saved settings, translated labels, money values, and data shaping.
|
|
6
|
+
|
|
7
|
+
This guide shows terminal-specific patterns and points you to the Valyrian.js module docs when you need complete API details.
|
|
8
|
+
|
|
9
|
+
Quick picks:
|
|
10
|
+
|
|
11
|
+
- Start with `valyrian.js` when you need the core runtime and component model for TSX terminal screens.
|
|
12
|
+
- Use `valyrian.js/pulses` for small reactive state and transitions inside one screen.
|
|
13
|
+
- Use `valyrian.js/flux-store` when updates need named operations, reducers, and workflow state.
|
|
14
|
+
- Use `valyrian.js/request`, `valyrian.js/query`, and `valyrian.js/tasks` for API clients, cached reads, and longer user-started work.
|
|
15
|
+
- Use `valyrian.js/forms`, `valyrian.js/native-store`, `valyrian.js/translate`, `valyrian.js/money`, and `valyrian.js/utils` for settings screens, saved preferences, labels, totals, and shaped display data.
|
|
16
|
+
- Start with the single-module examples, then combine modules through the composed workflows at the end.
|
|
17
|
+
|
|
18
|
+
Use `valyrian.js/translate`, `valyrian.js/money`, and `valyrian.js/utils` when a terminal screen presents product data, localized labels, totals, and shaped view models. Feed the renderer prepared display data while workflow rules stay in the app layer.
|
|
19
|
+
|
|
20
|
+
## Primary module examples
|
|
21
|
+
|
|
22
|
+
Each module below maps to a complete usable terminal demo. Run any file directly with Bun and quit with `Ctrl+C`. Each code block embeds the full runnable source so readers can copy, paste, and adapt the example without opening another file.
|
|
23
|
+
|
|
24
|
+
### `valyrian.js`
|
|
25
|
+
|
|
26
|
+
Build TSX terminal screens with the Valyrian component model. Demo app: **Terminal Command Palette**.
|
|
27
|
+
|
|
28
|
+
Complete demo: **Terminal Command Palette** ([`examples/docs/module-valyrian-core.tsx`](../examples/docs/module-valyrian-core.tsx)). Run it with `bun examples/docs/module-valyrian-core.tsx`, move with `J/K`, run a command with `Enter`, reset with `R`, and quit with `Ctrl+C`.
|
|
29
|
+
|
|
30
|
+
Complete runnable example:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { v } from "valyrian.js";
|
|
34
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
35
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
36
|
+
|
|
37
|
+
interface ModuleDemo {
|
|
38
|
+
session: TerminalSession;
|
|
39
|
+
dispatchKey(key: string): string;
|
|
40
|
+
output(): string;
|
|
41
|
+
ansiOutput(): string;
|
|
42
|
+
isRunning(): boolean;
|
|
43
|
+
destroy(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Command = {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
result: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type CommandPaletteState = {
|
|
53
|
+
selected: number;
|
|
54
|
+
result: string;
|
|
55
|
+
history: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const COMMANDS: Command[] = [
|
|
59
|
+
{ id: "open-incidents", name: "Open incident board", result: "Incident board opened" },
|
|
60
|
+
{ id: "assign-oncall", name: "Assign on-call engineer", result: "On-call engineer assigned" },
|
|
61
|
+
{ id: "publish-summary", name: "Publish shift summary", result: "Shift summary published" }
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const INITIAL_RESULT = "Choose a command";
|
|
65
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
66
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
67
|
+
|
|
68
|
+
function shouldRunSnapshot() {
|
|
69
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clampSelection(index: number) {
|
|
73
|
+
return (index + COMMANDS.length) % COMMANDS.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const manualNode = v(Text, null, "Manual vnode: quick actions are composed with TSX rows");
|
|
77
|
+
|
|
78
|
+
export function App({ state }: { state: CommandPaletteState }) {
|
|
79
|
+
return (
|
|
80
|
+
<Screen title="Terminal Command Palette">
|
|
81
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Terminal Command Palette</Text>
|
|
82
|
+
<Pane style={PANEL_STYLE}>
|
|
83
|
+
<Text>Command Palette</Text>
|
|
84
|
+
{manualNode}
|
|
85
|
+
{COMMANDS.map((command, index) => (
|
|
86
|
+
<Text>{`${index === state.selected ? ">" : " "} ${command.name}`}</Text>
|
|
87
|
+
))}
|
|
88
|
+
<Text>{`Result: ${state.result}`}</Text>
|
|
89
|
+
<Text>{`History: ${state.history.length ? state.history.join(" -> ") : "none"}`}</Text>
|
|
90
|
+
</Pane>
|
|
91
|
+
<Text style={FOOTER_STYLE}>J/K move Enter run command R reset Ctrl+C: quit</Text>
|
|
92
|
+
</Screen>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createModuleValyrianCoreDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
97
|
+
const state: CommandPaletteState = { selected: 0, result: INITIAL_RESULT, history: [] };
|
|
98
|
+
let running = true;
|
|
99
|
+
let session: TerminalSession;
|
|
100
|
+
|
|
101
|
+
function quit() {
|
|
102
|
+
running = false;
|
|
103
|
+
session.destroy();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runSelectedCommand() {
|
|
107
|
+
const command = COMMANDS[state.selected];
|
|
108
|
+
state.result = command.result;
|
|
109
|
+
state.history = [...state.history.slice(-2), command.name];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function reset() {
|
|
113
|
+
state.selected = 0;
|
|
114
|
+
state.result = INITIAL_RESULT;
|
|
115
|
+
state.history = [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
session = mountTerminal(<App state={state} />, {
|
|
119
|
+
...options,
|
|
120
|
+
cols: options.cols ?? 92,
|
|
121
|
+
rows: options.rows ?? 18,
|
|
122
|
+
keymap: {
|
|
123
|
+
...options.keymap,
|
|
124
|
+
bindings: [
|
|
125
|
+
...(options.keymap?.bindings || []),
|
|
126
|
+
{ key: "j", command: { id: "palette.next" }, scope: "global" },
|
|
127
|
+
{ key: "J", command: { id: "palette.next" }, scope: "global" },
|
|
128
|
+
{ key: "k", command: { id: "palette.previous" }, scope: "global" },
|
|
129
|
+
{ key: "K", command: { id: "palette.previous" }, scope: "global" },
|
|
130
|
+
{ key: "ENTER", command: { id: "palette.run" }, scope: "global" },
|
|
131
|
+
{ key: "r", command: { id: "palette.reset" }, scope: "global" },
|
|
132
|
+
{ key: "R", command: { id: "palette.reset" }, scope: "global" },
|
|
133
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
134
|
+
],
|
|
135
|
+
onCommand(command, context) {
|
|
136
|
+
if (command.id === "palette.next") {
|
|
137
|
+
state.selected = clampSelection(state.selected + 1);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (command.id === "palette.previous") {
|
|
141
|
+
state.selected = clampSelection(state.selected - 1);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (command.id === "palette.run") {
|
|
145
|
+
runSelectedCommand();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (command.id === "palette.reset") {
|
|
149
|
+
reset();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (command.id === "quit") {
|
|
153
|
+
quit();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return options.keymap?.onCommand?.(command, context);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
session,
|
|
163
|
+
dispatchKey(key: string) {
|
|
164
|
+
return session.dispatchKey(key);
|
|
165
|
+
},
|
|
166
|
+
output() {
|
|
167
|
+
return session.output();
|
|
168
|
+
},
|
|
169
|
+
ansiOutput() {
|
|
170
|
+
return session.ansiOutput();
|
|
171
|
+
},
|
|
172
|
+
isRunning() {
|
|
173
|
+
return running;
|
|
174
|
+
},
|
|
175
|
+
destroy() {
|
|
176
|
+
running = false;
|
|
177
|
+
session.destroy();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (import.meta.main) {
|
|
183
|
+
if (shouldRunSnapshot()) {
|
|
184
|
+
const demo = createModuleValyrianCoreDemo({ runtime: "headless", cols: 92, rows: 18 });
|
|
185
|
+
process.stdout.write(demo.output());
|
|
186
|
+
process.stdout.write("\n");
|
|
187
|
+
demo.destroy();
|
|
188
|
+
} else {
|
|
189
|
+
createModuleValyrianCoreDemo();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See the Valyrian.js documentation for the full `valyrian.js` API.
|
|
195
|
+
|
|
196
|
+
### `valyrian.js/pulses`
|
|
197
|
+
|
|
198
|
+
Add reactive counters, toggles, and lightweight shared state. Demo app: **Live Shift/Ticket Counter**.
|
|
199
|
+
|
|
200
|
+
Complete demo: **Live Shift/Ticket Counter** ([`examples/docs/module-pulses.tsx`](../examples/docs/module-pulses.tsx)). Run it with `bun examples/docs/module-pulses.tsx`, add tickets with `A`, resolve tickets with `D`, toggle the priority lane with `P`, reset with `R`, and quit with `Ctrl+C`.
|
|
201
|
+
|
|
202
|
+
Complete runnable example:
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
import { createPulse } from "valyrian.js/pulses";
|
|
206
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
207
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
208
|
+
|
|
209
|
+
interface ModuleDemo {
|
|
210
|
+
session: TerminalSession;
|
|
211
|
+
dispatchKey(key: string): string;
|
|
212
|
+
output(): string;
|
|
213
|
+
ansiOutput(): string;
|
|
214
|
+
isRunning(): boolean;
|
|
215
|
+
destroy(): void;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type ShiftPulses = {
|
|
219
|
+
open: () => number;
|
|
220
|
+
resolved: () => number;
|
|
221
|
+
priority: () => boolean;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const INITIAL_OPEN = 4;
|
|
225
|
+
const INITIAL_RESOLVED = 1;
|
|
226
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
227
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
228
|
+
|
|
229
|
+
function shouldRunSnapshot() {
|
|
230
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function App({ pulses }: { pulses: ShiftPulses }) {
|
|
234
|
+
const open = pulses.open();
|
|
235
|
+
const resolved = pulses.resolved();
|
|
236
|
+
const priority = pulses.priority();
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Screen title="Live Shift/Ticket Counter">
|
|
240
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Live Shift/Ticket Counter</Text>
|
|
241
|
+
<Pane style={PANEL_STYLE}>
|
|
242
|
+
<Text>Shift/Ticket Counter</Text>
|
|
243
|
+
<Text>{`Open tickets: ${open}`}</Text>
|
|
244
|
+
<Text>{`Resolved this shift: ${resolved}`}</Text>
|
|
245
|
+
<Text>{`Priority lane: ${priority ? "active" : "normal"}`}</Text>
|
|
246
|
+
<Text>{`Workload: ${open > 5 || priority ? "watch closely" : "steady"}`}</Text>
|
|
247
|
+
</Pane>
|
|
248
|
+
<Text style={FOOTER_STYLE}>A add ticket D resolve ticket P priority lane R reset Ctrl+C: quit</Text>
|
|
249
|
+
</Screen>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createModulePulsesDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
254
|
+
const [openTickets, setOpenTickets] = createPulse(INITIAL_OPEN);
|
|
255
|
+
const [resolvedTickets, setResolvedTickets] = createPulse(INITIAL_RESOLVED);
|
|
256
|
+
const [priorityActive, setPriorityActive] = createPulse(false);
|
|
257
|
+
let running = true;
|
|
258
|
+
let session: TerminalSession;
|
|
259
|
+
|
|
260
|
+
function quit() {
|
|
261
|
+
running = false;
|
|
262
|
+
session.destroy();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function reset() {
|
|
266
|
+
setOpenTickets(INITIAL_OPEN);
|
|
267
|
+
setResolvedTickets(INITIAL_RESOLVED);
|
|
268
|
+
setPriorityActive(false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
session = mountTerminal(<App pulses={{ open: openTickets, resolved: resolvedTickets, priority: priorityActive }} />, {
|
|
272
|
+
...options,
|
|
273
|
+
cols: options.cols ?? 92,
|
|
274
|
+
rows: options.rows ?? 16,
|
|
275
|
+
keymap: {
|
|
276
|
+
...options.keymap,
|
|
277
|
+
bindings: [
|
|
278
|
+
...(options.keymap?.bindings || []),
|
|
279
|
+
{ key: "a", command: { id: "tickets.add" }, scope: "global" },
|
|
280
|
+
{ key: "A", command: { id: "tickets.add" }, scope: "global" },
|
|
281
|
+
{ key: "d", command: { id: "tickets.resolve" }, scope: "global" },
|
|
282
|
+
{ key: "D", command: { id: "tickets.resolve" }, scope: "global" },
|
|
283
|
+
{ key: "p", command: { id: "tickets.priority" }, scope: "global" },
|
|
284
|
+
{ key: "P", command: { id: "tickets.priority" }, scope: "global" },
|
|
285
|
+
{ key: "r", command: { id: "tickets.reset" }, scope: "global" },
|
|
286
|
+
{ key: "R", command: { id: "tickets.reset" }, scope: "global" },
|
|
287
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
288
|
+
],
|
|
289
|
+
onCommand(command, context) {
|
|
290
|
+
if (command.id === "tickets.add") {
|
|
291
|
+
setOpenTickets((value) => value + 1);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (command.id === "tickets.resolve") {
|
|
295
|
+
setOpenTickets((value) => Math.max(0, value - 1));
|
|
296
|
+
setResolvedTickets((value) => value + 1);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (command.id === "tickets.priority") {
|
|
300
|
+
setPriorityActive((value) => !value);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
if (command.id === "tickets.reset") {
|
|
304
|
+
reset();
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (command.id === "quit") {
|
|
308
|
+
quit();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return options.keymap?.onCommand?.(command, context);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
session,
|
|
318
|
+
dispatchKey(key: string) {
|
|
319
|
+
return session.dispatchKey(key);
|
|
320
|
+
},
|
|
321
|
+
output() {
|
|
322
|
+
return session.output();
|
|
323
|
+
},
|
|
324
|
+
ansiOutput() {
|
|
325
|
+
return session.ansiOutput();
|
|
326
|
+
},
|
|
327
|
+
isRunning() {
|
|
328
|
+
return running;
|
|
329
|
+
},
|
|
330
|
+
destroy() {
|
|
331
|
+
running = false;
|
|
332
|
+
session.destroy();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (import.meta.main) {
|
|
338
|
+
if (shouldRunSnapshot()) {
|
|
339
|
+
const demo = createModulePulsesDemo({ runtime: "headless", cols: 92, rows: 16 });
|
|
340
|
+
process.stdout.write(demo.output());
|
|
341
|
+
process.stdout.write("\n");
|
|
342
|
+
demo.destroy();
|
|
343
|
+
} else {
|
|
344
|
+
createModulePulsesDemo();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
See the Valyrian.js documentation for the full `valyrian.js/pulses` API.
|
|
350
|
+
|
|
351
|
+
### `valyrian.js/flux-store`
|
|
352
|
+
|
|
353
|
+
Model command workflows with named operations and shared app state. Demo app: **Operations Queue Manager**.
|
|
354
|
+
|
|
355
|
+
Complete demo: **Operations Queue Manager** ([`examples/docs/module-flux-store.tsx`](../examples/docs/module-flux-store.tsx)). Run it with `bun examples/docs/module-flux-store.tsx`, select with `J/K`, start with `S`, mark done with `D`, block with `B`, reset with `R`, and quit with `Ctrl+C`.
|
|
356
|
+
|
|
357
|
+
Complete runnable example:
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
import { FluxStore } from "valyrian.js/flux-store";
|
|
361
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
362
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
363
|
+
|
|
364
|
+
interface ModuleDemo {
|
|
365
|
+
session: TerminalSession;
|
|
366
|
+
dispatchKey(key: string): string;
|
|
367
|
+
output(): string;
|
|
368
|
+
ansiOutput(): string;
|
|
369
|
+
isRunning(): boolean;
|
|
370
|
+
destroy(): void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
type JobStatus = "queued" | "running" | "done" | "blocked";
|
|
374
|
+
type Job = { name: string; status: JobStatus };
|
|
375
|
+
type QueueState = { selected: number; jobs: Job[] };
|
|
376
|
+
|
|
377
|
+
type QueueGetters = { doneCount: number; activeJob: string };
|
|
378
|
+
type QueueStore = FluxStore & { state: QueueState; getters: QueueGetters };
|
|
379
|
+
|
|
380
|
+
const INITIAL_JOBS: Job[] = [
|
|
381
|
+
{ name: "Sync warehouse feed", status: "queued" },
|
|
382
|
+
{ name: "Package release", status: "queued" },
|
|
383
|
+
{ name: "Review failed payouts", status: "blocked" }
|
|
384
|
+
];
|
|
385
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
386
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
387
|
+
|
|
388
|
+
function shouldRunSnapshot() {
|
|
389
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function createQueueStore(): QueueStore {
|
|
393
|
+
return new FluxStore({
|
|
394
|
+
state: { selected: 0, jobs: INITIAL_JOBS.map((job) => ({ ...job })) },
|
|
395
|
+
mutations: {
|
|
396
|
+
SELECT_NEXT(state) {
|
|
397
|
+
const queueState = state as QueueState;
|
|
398
|
+
queueState.selected = (queueState.selected + 1) % queueState.jobs.length;
|
|
399
|
+
},
|
|
400
|
+
SELECT_PREVIOUS(state) {
|
|
401
|
+
const queueState = state as QueueState;
|
|
402
|
+
queueState.selected = (queueState.selected - 1 + queueState.jobs.length) % queueState.jobs.length;
|
|
403
|
+
},
|
|
404
|
+
SET_STATUS(state, status: JobStatus) {
|
|
405
|
+
const queueState = state as QueueState;
|
|
406
|
+
queueState.jobs = queueState.jobs.map((job, index) => (index === queueState.selected ? { ...job, status } : job));
|
|
407
|
+
},
|
|
408
|
+
RESET(state) {
|
|
409
|
+
const queueState = state as QueueState;
|
|
410
|
+
queueState.selected = 0;
|
|
411
|
+
queueState.jobs = INITIAL_JOBS.map((job) => ({ ...job }));
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
getters: {
|
|
415
|
+
doneCount(state) {
|
|
416
|
+
const queueState = state as QueueState;
|
|
417
|
+
return queueState.jobs.filter((job) => job.status === "done").length;
|
|
418
|
+
},
|
|
419
|
+
activeJob(state) {
|
|
420
|
+
const queueState = state as QueueState;
|
|
421
|
+
return queueState.jobs[queueState.selected].name;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}) as QueueStore;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function App({ store }: { store: QueueStore }) {
|
|
428
|
+
return (
|
|
429
|
+
<Screen title="Operations Queue Manager">
|
|
430
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Operations Queue Manager</Text>
|
|
431
|
+
<Pane style={PANEL_STYLE}>
|
|
432
|
+
<Text>Queue Manager</Text>
|
|
433
|
+
{store.state.jobs.map((job, index) => (
|
|
434
|
+
<Text>{`${index === store.state.selected ? ">" : " "} ${job.name} — ${job.status}`}</Text>
|
|
435
|
+
))}
|
|
436
|
+
<Text>{`Selected: ${store.getters.activeJob}`}</Text>
|
|
437
|
+
<Text>{`Done jobs: ${store.getters.doneCount}`}</Text>
|
|
438
|
+
</Pane>
|
|
439
|
+
<Text style={FOOTER_STYLE}>J/K select S start D mark done B block R reset Ctrl+C: quit</Text>
|
|
440
|
+
</Screen>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function createModuleFluxStoreDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
445
|
+
const store = createQueueStore();
|
|
446
|
+
let running = true;
|
|
447
|
+
let session: TerminalSession;
|
|
448
|
+
|
|
449
|
+
function quit() {
|
|
450
|
+
running = false;
|
|
451
|
+
session.destroy();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
session = mountTerminal(<App store={store} />, {
|
|
455
|
+
...options,
|
|
456
|
+
cols: options.cols ?? 92,
|
|
457
|
+
rows: options.rows ?? 18,
|
|
458
|
+
keymap: {
|
|
459
|
+
...options.keymap,
|
|
460
|
+
bindings: [
|
|
461
|
+
...(options.keymap?.bindings || []),
|
|
462
|
+
{ key: "j", command: { id: "queue.next" }, scope: "global" },
|
|
463
|
+
{ key: "J", command: { id: "queue.next" }, scope: "global" },
|
|
464
|
+
{ key: "k", command: { id: "queue.previous" }, scope: "global" },
|
|
465
|
+
{ key: "K", command: { id: "queue.previous" }, scope: "global" },
|
|
466
|
+
{ key: "s", command: { id: "queue.start" }, scope: "global" },
|
|
467
|
+
{ key: "S", command: { id: "queue.start" }, scope: "global" },
|
|
468
|
+
{ key: "d", command: { id: "queue.done" }, scope: "global" },
|
|
469
|
+
{ key: "D", command: { id: "queue.done" }, scope: "global" },
|
|
470
|
+
{ key: "b", command: { id: "queue.block" }, scope: "global" },
|
|
471
|
+
{ key: "B", command: { id: "queue.block" }, scope: "global" },
|
|
472
|
+
{ key: "r", command: { id: "queue.reset" }, scope: "global" },
|
|
473
|
+
{ key: "R", command: { id: "queue.reset" }, scope: "global" },
|
|
474
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
475
|
+
],
|
|
476
|
+
onCommand(command, context) {
|
|
477
|
+
if (command.id === "queue.next") {
|
|
478
|
+
store.commit("SELECT_NEXT");
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
if (command.id === "queue.previous") {
|
|
482
|
+
store.commit("SELECT_PREVIOUS");
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (command.id === "queue.start") {
|
|
486
|
+
store.commit("SET_STATUS", "running");
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
if (command.id === "queue.done") {
|
|
490
|
+
store.commit("SET_STATUS", "done");
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
if (command.id === "queue.block") {
|
|
494
|
+
store.commit("SET_STATUS", "blocked");
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
if (command.id === "queue.reset") {
|
|
498
|
+
store.commit("RESET");
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
if (command.id === "quit") {
|
|
502
|
+
quit();
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
return options.keymap?.onCommand?.(command, context);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
session,
|
|
512
|
+
dispatchKey(key: string) {
|
|
513
|
+
return session.dispatchKey(key);
|
|
514
|
+
},
|
|
515
|
+
output() {
|
|
516
|
+
return session.output();
|
|
517
|
+
},
|
|
518
|
+
ansiOutput() {
|
|
519
|
+
return session.ansiOutput();
|
|
520
|
+
},
|
|
521
|
+
isRunning() {
|
|
522
|
+
return running;
|
|
523
|
+
},
|
|
524
|
+
destroy() {
|
|
525
|
+
running = false;
|
|
526
|
+
session.destroy();
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (import.meta.main) {
|
|
532
|
+
if (shouldRunSnapshot()) {
|
|
533
|
+
const demo = createModuleFluxStoreDemo({ runtime: "headless", cols: 92, rows: 18 });
|
|
534
|
+
process.stdout.write(demo.output());
|
|
535
|
+
process.stdout.write("\n");
|
|
536
|
+
demo.destroy();
|
|
537
|
+
} else {
|
|
538
|
+
createModuleFluxStoreDemo();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
See the Valyrian.js documentation for the full `valyrian.js/flux-store` API.
|
|
544
|
+
|
|
545
|
+
### `valyrian.js/request`
|
|
546
|
+
|
|
547
|
+
Connect terminal screens to request helpers, parsing, and endpoint access. Demo app: **Service Status Client**.
|
|
548
|
+
|
|
549
|
+
Complete demo: **Service Status Client** ([`examples/docs/module-request.tsx`](../examples/docs/module-request.tsx)). Run it with `bun examples/docs/module-request.tsx`, refresh status with `R`, force the next controlled error with `E`, and quit with `Ctrl+C`.
|
|
550
|
+
|
|
551
|
+
Complete runnable example:
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
import { request } from "valyrian.js/request";
|
|
555
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
556
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
557
|
+
|
|
558
|
+
interface ModuleDemo {
|
|
559
|
+
session: TerminalSession;
|
|
560
|
+
dispatchKey(key: string): string;
|
|
561
|
+
output(): string;
|
|
562
|
+
ansiOutput(): string;
|
|
563
|
+
isRunning(): boolean;
|
|
564
|
+
destroy(): void;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
interface RequestState {
|
|
568
|
+
status: "idle" | "loading" | "success" | "error";
|
|
569
|
+
message: string;
|
|
570
|
+
fetchedPath: string;
|
|
571
|
+
lastError: string;
|
|
572
|
+
failNext: boolean;
|
|
573
|
+
history: Array<"success" | "error">;
|
|
574
|
+
running: boolean;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
578
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
579
|
+
|
|
580
|
+
function shouldRunSnapshot() {
|
|
581
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function createState(): RequestState {
|
|
585
|
+
return {
|
|
586
|
+
status: "idle",
|
|
587
|
+
message: "Ready to check the service status",
|
|
588
|
+
fetchedPath: "none",
|
|
589
|
+
lastError: "none",
|
|
590
|
+
failNext: false,
|
|
591
|
+
history: [],
|
|
592
|
+
running: true
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function historySummary(history: RequestState["history"]) {
|
|
597
|
+
return history.length === 0 ? "none" : history.join(" -> ");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const api = request.new("http://terminal.local", { allowedMethods: ["get"] });
|
|
601
|
+
|
|
602
|
+
async function withFakeFetch<T>(shouldFail: boolean, run: () => Promise<T>) {
|
|
603
|
+
const originalFetch = globalThis.fetch;
|
|
604
|
+
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
605
|
+
const url = new URL(String(input));
|
|
606
|
+
if (shouldFail) {
|
|
607
|
+
return Promise.resolve(
|
|
608
|
+
new Response(JSON.stringify({ message: "controlled local failure" }), { status: 503 })
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
return Promise.resolve(
|
|
612
|
+
new Response(JSON.stringify({ status: "ok", path: `${url.pathname}${url.search}` }), { status: 200 })
|
|
613
|
+
);
|
|
614
|
+
}) as typeof fetch;
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
return await run();
|
|
618
|
+
} finally {
|
|
619
|
+
globalThis.fetch = originalFetch;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function App({ state }: { state: RequestState }) {
|
|
624
|
+
return (
|
|
625
|
+
<Screen title="Service Status Client">
|
|
626
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Service Status Client</Text>
|
|
627
|
+
<Pane style={PANEL_STYLE}>
|
|
628
|
+
<Text>Status Client: checks a local service endpoint without using the network.</Text>
|
|
629
|
+
<Text>{`Request status: ${state.status}`}</Text>
|
|
630
|
+
<Text>{state.message}</Text>
|
|
631
|
+
<Text>{`Fetched: ${state.fetchedPath}`}</Text>
|
|
632
|
+
<Text>{`Last error: ${state.lastError}`}</Text>
|
|
633
|
+
<Text>{`Next request: ${state.failNext ? "controlled error" : "success"}`}</Text>
|
|
634
|
+
<Text>{`History: ${historySummary(state.history)}`}</Text>
|
|
635
|
+
</Pane>
|
|
636
|
+
<Text style={FOOTER_STYLE}>R: refresh status E: force next error Ctrl+C: quit</Text>
|
|
637
|
+
</Screen>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function createModuleRequestDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
642
|
+
const state = createState();
|
|
643
|
+
let session: TerminalSession;
|
|
644
|
+
|
|
645
|
+
async function loadStatus() {
|
|
646
|
+
if (state.status === "loading") {
|
|
647
|
+
state.message = "Refresh already in progress";
|
|
648
|
+
session.update();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
state.status = "loading";
|
|
653
|
+
state.message = "Loading local service status";
|
|
654
|
+
session.update();
|
|
655
|
+
try {
|
|
656
|
+
const shouldFail = state.failNext;
|
|
657
|
+
state.failNext = false;
|
|
658
|
+
const body = (await withFakeFetch(shouldFail, () => api.get("/status", { page: 1 }))) as { status: string; path: string };
|
|
659
|
+
state.status = "success";
|
|
660
|
+
state.history.push("success");
|
|
661
|
+
state.message = state.lastError === "none" ? `HTTP status: ${body.status}` : `Retry result: ${body.status}`;
|
|
662
|
+
state.fetchedPath = body.path;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
state.status = "error";
|
|
665
|
+
state.history.push("error");
|
|
666
|
+
state.message = error instanceof Error ? error.message : "Request failed";
|
|
667
|
+
state.lastError = state.message;
|
|
668
|
+
state.fetchedPath = "/status?page=1";
|
|
669
|
+
}
|
|
670
|
+
session.update();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function quit() {
|
|
674
|
+
state.running = false;
|
|
675
|
+
session.destroy();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
session = mountTerminal(<App state={state} />, {
|
|
679
|
+
...options,
|
|
680
|
+
cols: options.cols ?? 92,
|
|
681
|
+
rows: options.rows ?? 16,
|
|
682
|
+
keymap: {
|
|
683
|
+
...options.keymap,
|
|
684
|
+
bindings: [
|
|
685
|
+
...(options.keymap?.bindings || []),
|
|
686
|
+
{ key: "r", command: { id: "request.load" }, scope: "global" },
|
|
687
|
+
{ key: "R", command: { id: "request.load" }, scope: "global" },
|
|
688
|
+
{ key: "e", command: { id: "request.failNext" }, scope: "global" },
|
|
689
|
+
{ key: "E", command: { id: "request.failNext" }, scope: "global" },
|
|
690
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
691
|
+
],
|
|
692
|
+
onCommand(command, context) {
|
|
693
|
+
if (command.id === "request.load") {
|
|
694
|
+
void loadStatus();
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
if (command.id === "request.failNext") {
|
|
698
|
+
state.failNext = true;
|
|
699
|
+
state.message = "Next refresh will use the controlled error path";
|
|
700
|
+
session.update();
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
if (command.id === "quit") {
|
|
704
|
+
quit();
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
return options.keymap?.onCommand?.(command, context);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
session,
|
|
714
|
+
dispatchKey(key: string) {
|
|
715
|
+
return session.dispatchKey(key);
|
|
716
|
+
},
|
|
717
|
+
output() {
|
|
718
|
+
return session.output();
|
|
719
|
+
},
|
|
720
|
+
ansiOutput() {
|
|
721
|
+
return session.ansiOutput();
|
|
722
|
+
},
|
|
723
|
+
isRunning() {
|
|
724
|
+
return state.running;
|
|
725
|
+
},
|
|
726
|
+
destroy() {
|
|
727
|
+
state.running = false;
|
|
728
|
+
session.destroy();
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (import.meta.main) {
|
|
734
|
+
if (shouldRunSnapshot()) {
|
|
735
|
+
const demo = createModuleRequestDemo({ runtime: "headless", cols: 92, rows: 16 });
|
|
736
|
+
demo.dispatchKey("E");
|
|
737
|
+
demo.dispatchKey("R");
|
|
738
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
739
|
+
demo.dispatchKey("R");
|
|
740
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
741
|
+
process.stdout.write(demo.output());
|
|
742
|
+
process.stdout.write("\n");
|
|
743
|
+
demo.destroy();
|
|
744
|
+
} else {
|
|
745
|
+
createModuleRequestDemo();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
See the Valyrian.js documentation for the full `valyrian.js/request` API.
|
|
751
|
+
|
|
752
|
+
### `valyrian.js/query`
|
|
753
|
+
|
|
754
|
+
Show cached read state with fetch, invalidate, status, and data lifecycle. Demo app: **Cached Inventory Browser**.
|
|
755
|
+
|
|
756
|
+
Complete demo: **Cached Inventory Browser** ([`examples/docs/module-query.tsx`](../examples/docs/module-query.tsx)). Run it with `bun examples/docs/module-query.tsx`, fetch inventory with `F`, invalidate cache with `I`, select items with `J/K`, and quit with `Ctrl+C`.
|
|
757
|
+
|
|
758
|
+
Complete runnable example:
|
|
759
|
+
|
|
760
|
+
```tsx
|
|
761
|
+
import { QueryClient } from "valyrian.js/query";
|
|
762
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
763
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
764
|
+
|
|
765
|
+
interface ModuleDemo {
|
|
766
|
+
session: TerminalSession;
|
|
767
|
+
dispatchKey(key: string): string;
|
|
768
|
+
output(): string;
|
|
769
|
+
ansiOutput(): string;
|
|
770
|
+
isRunning(): boolean;
|
|
771
|
+
destroy(): void;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
interface InventoryItem {
|
|
775
|
+
sku: string;
|
|
776
|
+
name: string;
|
|
777
|
+
category: string;
|
|
778
|
+
stock: number;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
interface InventoryResult {
|
|
782
|
+
readLabel: string;
|
|
783
|
+
items: InventoryItem[];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
interface QueryState {
|
|
787
|
+
value: string;
|
|
788
|
+
status: string;
|
|
789
|
+
invalidated: boolean;
|
|
790
|
+
fetchCount: number;
|
|
791
|
+
selectedIndex: number;
|
|
792
|
+
items: InventoryItem[];
|
|
793
|
+
running: boolean;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
797
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
798
|
+
|
|
799
|
+
const INVENTORY: InventoryItem[] = [
|
|
800
|
+
{ sku: "OPS-100", name: "Battery pack", category: "Operations", stock: 12 },
|
|
801
|
+
{ sku: "NET-210", name: "Switch kit", category: "Network", stock: 4 },
|
|
802
|
+
{ sku: "SEC-404", name: "Seal tags", category: "Security", stock: 30 }
|
|
803
|
+
];
|
|
804
|
+
|
|
805
|
+
function shouldRunSnapshot() {
|
|
806
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function selectedItem(state: QueryState) {
|
|
810
|
+
return state.items[state.selectedIndex] || null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function App({ state }: { state: QueryState }) {
|
|
814
|
+
const item = selectedItem(state);
|
|
815
|
+
return (
|
|
816
|
+
<Screen title="Cached Inventory Browser">
|
|
817
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Cached Inventory Browser</Text>
|
|
818
|
+
<Pane style={PANEL_STYLE}>
|
|
819
|
+
<Text>Inventory Browser: fetches local stock data through a Valyrian query cache.</Text>
|
|
820
|
+
<Text>{`Query status: ${state.status}`}</Text>
|
|
821
|
+
<Text>{`Cached value: ${state.value}`}</Text>
|
|
822
|
+
<Text>{`Invalidated: ${state.invalidated ? "yes" : "no"}`}</Text>
|
|
823
|
+
<Text>{`Fetcher calls: ${state.fetchCount}`}</Text>
|
|
824
|
+
<Text>{`Selected item: ${item ? `${item.name} (${item.sku})` : "none"}`}</Text>
|
|
825
|
+
<Text>{`Category: ${item ? item.category : "none"}`}</Text>
|
|
826
|
+
<Text>{`Stock on hand: ${item ? item.stock : 0}`}</Text>
|
|
827
|
+
</Pane>
|
|
828
|
+
<Text style={FOOTER_STYLE}>F: fetch inventory I: invalidate cache J/K: select item Ctrl+C: quit</Text>
|
|
829
|
+
</Screen>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function createModuleQueryDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
834
|
+
const state: QueryState = {
|
|
835
|
+
value: "none",
|
|
836
|
+
status: "idle",
|
|
837
|
+
invalidated: false,
|
|
838
|
+
fetchCount: 0,
|
|
839
|
+
selectedIndex: 0,
|
|
840
|
+
items: [],
|
|
841
|
+
running: true
|
|
842
|
+
};
|
|
843
|
+
const client = new QueryClient({ staleTime: 30000, cacheTime: 1000 });
|
|
844
|
+
const query = client.query({
|
|
845
|
+
key: ["docs", "inventory"],
|
|
846
|
+
fetcher: (): InventoryResult => {
|
|
847
|
+
state.fetchCount += 1;
|
|
848
|
+
return {
|
|
849
|
+
readLabel: `terminal read #${state.fetchCount}`,
|
|
850
|
+
items: INVENTORY.map((item) => ({ ...item }))
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
let session: TerminalSession;
|
|
855
|
+
|
|
856
|
+
async function fetchInventory() {
|
|
857
|
+
state.status = "loading";
|
|
858
|
+
session.update();
|
|
859
|
+
const data = await query.fetch();
|
|
860
|
+
state.value = data?.readLabel || "none";
|
|
861
|
+
state.items = data?.items || [];
|
|
862
|
+
state.selectedIndex = Math.min(state.selectedIndex, Math.max(state.items.length - 1, 0));
|
|
863
|
+
state.status = query.state.status;
|
|
864
|
+
session.update();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function invalidateInventory() {
|
|
868
|
+
query.invalidate();
|
|
869
|
+
state.status = query.state.status;
|
|
870
|
+
state.invalidated = true;
|
|
871
|
+
session.update();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function moveSelection(delta: number) {
|
|
875
|
+
if (state.items.length === 0) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
state.selectedIndex = (state.selectedIndex + delta + state.items.length) % state.items.length;
|
|
879
|
+
session.update();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function quit() {
|
|
883
|
+
state.running = false;
|
|
884
|
+
client.clear();
|
|
885
|
+
session.destroy();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
session = mountTerminal(<App state={state} />, {
|
|
889
|
+
...options,
|
|
890
|
+
cols: options.cols ?? 92,
|
|
891
|
+
rows: options.rows ?? 16,
|
|
892
|
+
keymap: {
|
|
893
|
+
...options.keymap,
|
|
894
|
+
bindings: [
|
|
895
|
+
...(options.keymap?.bindings || []),
|
|
896
|
+
{ key: "f", command: { id: "query.fetch" }, scope: "global" },
|
|
897
|
+
{ key: "F", command: { id: "query.fetch" }, scope: "global" },
|
|
898
|
+
{ key: "i", command: { id: "query.invalidate" }, scope: "global" },
|
|
899
|
+
{ key: "I", command: { id: "query.invalidate" }, scope: "global" },
|
|
900
|
+
{ key: "j", command: { id: "inventory.next" }, scope: "global" },
|
|
901
|
+
{ key: "J", command: { id: "inventory.next" }, scope: "global" },
|
|
902
|
+
{ key: "k", command: { id: "inventory.previous" }, scope: "global" },
|
|
903
|
+
{ key: "K", command: { id: "inventory.previous" }, scope: "global" },
|
|
904
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
905
|
+
],
|
|
906
|
+
onCommand(command, context) {
|
|
907
|
+
if (command.id === "query.fetch") {
|
|
908
|
+
void fetchInventory();
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
if (command.id === "query.invalidate") {
|
|
912
|
+
invalidateInventory();
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
if (command.id === "inventory.next") {
|
|
916
|
+
moveSelection(1);
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
if (command.id === "inventory.previous") {
|
|
920
|
+
moveSelection(-1);
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
if (command.id === "quit") {
|
|
924
|
+
quit();
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
return options.keymap?.onCommand?.(command, context);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
session,
|
|
934
|
+
dispatchKey(key: string) {
|
|
935
|
+
return session.dispatchKey(key);
|
|
936
|
+
},
|
|
937
|
+
output() {
|
|
938
|
+
return session.output();
|
|
939
|
+
},
|
|
940
|
+
ansiOutput() {
|
|
941
|
+
return session.ansiOutput();
|
|
942
|
+
},
|
|
943
|
+
isRunning() {
|
|
944
|
+
return state.running;
|
|
945
|
+
},
|
|
946
|
+
destroy() {
|
|
947
|
+
state.running = false;
|
|
948
|
+
client.clear();
|
|
949
|
+
session.destroy();
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (import.meta.main) {
|
|
955
|
+
if (shouldRunSnapshot()) {
|
|
956
|
+
const demo = createModuleQueryDemo({ runtime: "headless", cols: 92, rows: 16 });
|
|
957
|
+
demo.dispatchKey("F");
|
|
958
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
959
|
+
demo.dispatchKey("J");
|
|
960
|
+
demo.dispatchKey("I");
|
|
961
|
+
demo.dispatchKey("F");
|
|
962
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
963
|
+
process.stdout.write(demo.output());
|
|
964
|
+
process.stdout.write("\n");
|
|
965
|
+
demo.destroy();
|
|
966
|
+
} else {
|
|
967
|
+
createModuleQueryDemo();
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
See the Valyrian.js documentation for the full `valyrian.js/query` API.
|
|
973
|
+
|
|
974
|
+
### `valyrian.js/tasks`
|
|
975
|
+
|
|
976
|
+
Expose async work with run, cancel, reset, success, and error states. Demo app: **Background Maintenance Runner**.
|
|
977
|
+
|
|
978
|
+
Complete demo: **Background Maintenance Runner** ([`examples/docs/module-tasks.tsx`](../examples/docs/module-tasks.tsx)). Run it with `bun examples/docs/module-tasks.tsx`, run cleanup with `R`, cancel the scan with `C`, trigger a controlled failure with `X`, reset with `Z`, and quit with `Ctrl+C`.
|
|
979
|
+
|
|
980
|
+
Complete runnable example:
|
|
981
|
+
|
|
982
|
+
```tsx
|
|
983
|
+
import { Task } from "valyrian.js/tasks";
|
|
984
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
985
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
986
|
+
|
|
987
|
+
interface ModuleDemo {
|
|
988
|
+
session: TerminalSession;
|
|
989
|
+
dispatchKey(key: string): string;
|
|
990
|
+
output(): string;
|
|
991
|
+
ansiOutput(): string;
|
|
992
|
+
isRunning(): boolean;
|
|
993
|
+
destroy(): void;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
interface TaskDemoState {
|
|
997
|
+
status: string;
|
|
998
|
+
result: string;
|
|
999
|
+
error: string;
|
|
1000
|
+
currentJob: string;
|
|
1001
|
+
seenStates: string[];
|
|
1002
|
+
running: boolean;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
1006
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
1007
|
+
|
|
1008
|
+
function shouldRunSnapshot() {
|
|
1009
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function waitForControlledAsync(signal: AbortSignal) {
|
|
1013
|
+
return new Promise<void>((resolve) => {
|
|
1014
|
+
const timer = setTimeout(resolve, 20);
|
|
1015
|
+
signal.addEventListener(
|
|
1016
|
+
"abort",
|
|
1017
|
+
() => {
|
|
1018
|
+
clearTimeout(timer);
|
|
1019
|
+
resolve();
|
|
1020
|
+
},
|
|
1021
|
+
{ once: true }
|
|
1022
|
+
);
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function syncTaskState(state: TaskDemoState, task: Task<string, string>) {
|
|
1027
|
+
const snapshot = task.state;
|
|
1028
|
+
state.status = snapshot.status;
|
|
1029
|
+
state.result = snapshot.result ? `Result: ${snapshot.result}` : "Result: none";
|
|
1030
|
+
state.error = snapshot.error instanceof Error ? `Error: ${snapshot.error.message}` : "Error: none";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function visibleTaskStates(states: string[]) {
|
|
1034
|
+
const compact: string[] = [];
|
|
1035
|
+
for (const status of states) {
|
|
1036
|
+
if ((status === "cancelled" || status === "error") && compact[compact.length - 1] === "running") {
|
|
1037
|
+
compact.pop();
|
|
1038
|
+
}
|
|
1039
|
+
if (compact[compact.length - 1] !== status) {
|
|
1040
|
+
compact.push(status);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return compact.join(" -> ");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function App({ state }: { state: TaskDemoState }) {
|
|
1047
|
+
return (
|
|
1048
|
+
<Screen title="Background Maintenance Runner">
|
|
1049
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Background Maintenance Runner</Text>
|
|
1050
|
+
<Pane style={PANEL_STYLE}>
|
|
1051
|
+
<Text>Maintenance Runner: runs local cleanup work through a Valyrian Task.</Text>
|
|
1052
|
+
<Text>{`Task status: ${state.status}`}</Text>
|
|
1053
|
+
<Text>{`Maintenance job: ${state.currentJob}`}</Text>
|
|
1054
|
+
<Text>{state.result}</Text>
|
|
1055
|
+
<Text>{state.error}</Text>
|
|
1056
|
+
<Text>{`Seen states: ${visibleTaskStates(state.seenStates)}`}</Text>
|
|
1057
|
+
</Pane>
|
|
1058
|
+
<Text style={FOOTER_STYLE}>R: run cleanup C: cancel scan X: controlled fail Z: reset Ctrl+C: quit</Text>
|
|
1059
|
+
</Screen>
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function createModuleTasksDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
1064
|
+
const state: TaskDemoState = {
|
|
1065
|
+
status: "idle",
|
|
1066
|
+
result: "Result: none",
|
|
1067
|
+
error: "Error: none",
|
|
1068
|
+
currentJob: "none",
|
|
1069
|
+
seenStates: ["idle"],
|
|
1070
|
+
running: true
|
|
1071
|
+
};
|
|
1072
|
+
const task = new Task<string, string>(async (label, { signal }) => {
|
|
1073
|
+
if (label === "fail") {
|
|
1074
|
+
throw new Error("controlled task failure");
|
|
1075
|
+
}
|
|
1076
|
+
if (label === "scan") {
|
|
1077
|
+
await waitForControlledAsync(signal);
|
|
1078
|
+
if (signal.aborted) return "Cancelled stale scan";
|
|
1079
|
+
}
|
|
1080
|
+
return `Completed ${label}`;
|
|
1081
|
+
}, { strategy: "restartable" });
|
|
1082
|
+
const rememberState = () => {
|
|
1083
|
+
const status = task.state.status;
|
|
1084
|
+
if (state.seenStates[state.seenStates.length - 1] !== status) {
|
|
1085
|
+
state.seenStates.push(status);
|
|
1086
|
+
}
|
|
1087
|
+
syncTaskState(state, task);
|
|
1088
|
+
};
|
|
1089
|
+
const offTaskState = task.on("state", () => {
|
|
1090
|
+
rememberState();
|
|
1091
|
+
session?.update();
|
|
1092
|
+
});
|
|
1093
|
+
let session: TerminalSession;
|
|
1094
|
+
|
|
1095
|
+
function quit() {
|
|
1096
|
+
state.running = false;
|
|
1097
|
+
offTaskState();
|
|
1098
|
+
task.reset();
|
|
1099
|
+
session.destroy();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function runTask(label: string, job: string) {
|
|
1103
|
+
state.currentJob = job;
|
|
1104
|
+
try {
|
|
1105
|
+
rememberState();
|
|
1106
|
+
await task.run(label);
|
|
1107
|
+
} catch {
|
|
1108
|
+
// The task state snapshot carries the controlled error message.
|
|
1109
|
+
}
|
|
1110
|
+
syncTaskState(state, task);
|
|
1111
|
+
session.update();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
session = mountTerminal(<App state={state} />, {
|
|
1115
|
+
...options,
|
|
1116
|
+
cols: options.cols ?? 92,
|
|
1117
|
+
rows: options.rows ?? 16,
|
|
1118
|
+
keymap: {
|
|
1119
|
+
...options.keymap,
|
|
1120
|
+
bindings: [
|
|
1121
|
+
...(options.keymap?.bindings || []),
|
|
1122
|
+
{ key: "r", command: { id: "task.run" }, scope: "global" },
|
|
1123
|
+
{ key: "R", command: { id: "task.run" }, scope: "global" },
|
|
1124
|
+
{ key: "c", command: { id: "task.cancel" }, scope: "global" },
|
|
1125
|
+
{ key: "C", command: { id: "task.cancel" }, scope: "global" },
|
|
1126
|
+
{ key: "x", command: { id: "task.error" }, scope: "global" },
|
|
1127
|
+
{ key: "X", command: { id: "task.error" }, scope: "global" },
|
|
1128
|
+
{ key: "z", command: { id: "task.reset" }, scope: "global" },
|
|
1129
|
+
{ key: "Z", command: { id: "task.reset" }, scope: "global" },
|
|
1130
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
1131
|
+
],
|
|
1132
|
+
onCommand(command, context) {
|
|
1133
|
+
if (command.id === "task.run") {
|
|
1134
|
+
void runTask("cleanup", "nightly cleanup");
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
if (command.id === "task.cancel") {
|
|
1138
|
+
state.currentJob = "stale scan";
|
|
1139
|
+
void task.run("scan");
|
|
1140
|
+
rememberState();
|
|
1141
|
+
task.cancel();
|
|
1142
|
+
rememberState();
|
|
1143
|
+
session.update();
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
if (command.id === "task.error") {
|
|
1147
|
+
void runTask("fail", "permission audit");
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
if (command.id === "task.reset") {
|
|
1151
|
+
state.currentJob = "none";
|
|
1152
|
+
task.reset();
|
|
1153
|
+
rememberState();
|
|
1154
|
+
session.update();
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
if (command.id === "quit") {
|
|
1158
|
+
quit();
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
return options.keymap?.onCommand?.(command, context);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
session,
|
|
1168
|
+
dispatchKey(key: string) {
|
|
1169
|
+
return session.dispatchKey(key);
|
|
1170
|
+
},
|
|
1171
|
+
output() {
|
|
1172
|
+
return session.output();
|
|
1173
|
+
},
|
|
1174
|
+
ansiOutput() {
|
|
1175
|
+
return session.ansiOutput();
|
|
1176
|
+
},
|
|
1177
|
+
isRunning() {
|
|
1178
|
+
return state.running;
|
|
1179
|
+
},
|
|
1180
|
+
destroy() {
|
|
1181
|
+
state.running = false;
|
|
1182
|
+
offTaskState();
|
|
1183
|
+
task.reset();
|
|
1184
|
+
session.destroy();
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (import.meta.main) {
|
|
1190
|
+
if (shouldRunSnapshot()) {
|
|
1191
|
+
const demo = createModuleTasksDemo({ runtime: "headless", cols: 92, rows: 16 });
|
|
1192
|
+
demo.dispatchKey("R");
|
|
1193
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1194
|
+
demo.dispatchKey("C");
|
|
1195
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1196
|
+
demo.dispatchKey("X");
|
|
1197
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1198
|
+
demo.dispatchKey("Z");
|
|
1199
|
+
process.stdout.write(demo.output());
|
|
1200
|
+
process.stdout.write("\n");
|
|
1201
|
+
demo.destroy();
|
|
1202
|
+
} else {
|
|
1203
|
+
createModuleTasksDemo();
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
See the Valyrian.js documentation for the full `valyrian.js/tasks` API.
|
|
1209
|
+
|
|
1210
|
+
### `valyrian.js/forms`
|
|
1211
|
+
|
|
1212
|
+
Build validation flows with `setField`, `validate()`, `submit()`, visible errors, and reset. Demo app: **Profile Editor**.
|
|
1213
|
+
|
|
1214
|
+
Complete demo: **Profile Editor** ([`examples/docs/module-forms.tsx`](../examples/docs/module-forms.tsx)). Run it with `bun examples/docs/module-forms.tsx`, type profile fields, save with `Enter`, reset with `R`, and quit with `Ctrl+C`.
|
|
1215
|
+
|
|
1216
|
+
Complete runnable example:
|
|
1217
|
+
|
|
1218
|
+
```tsx
|
|
1219
|
+
import { FormStore } from "valyrian.js/forms";
|
|
1220
|
+
import { Input, Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
1221
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
1222
|
+
|
|
1223
|
+
interface ModuleDemo {
|
|
1224
|
+
session: TerminalSession;
|
|
1225
|
+
dispatchKey(key: string): string;
|
|
1226
|
+
output(): string;
|
|
1227
|
+
ansiOutput(): string;
|
|
1228
|
+
isRunning(): boolean;
|
|
1229
|
+
destroy(): void;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
type ProfileForm = { name: string; email: string };
|
|
1233
|
+
|
|
1234
|
+
interface SavedProfile {
|
|
1235
|
+
name: string;
|
|
1236
|
+
email: string;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
interface FormDemoState {
|
|
1240
|
+
form: FormStore<ProfileForm>;
|
|
1241
|
+
valid: boolean;
|
|
1242
|
+
savedProfile: SavedProfile | null;
|
|
1243
|
+
submitMessage: string;
|
|
1244
|
+
resetMessage: string;
|
|
1245
|
+
running: boolean;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
1249
|
+
const FIELD_STYLE = { color: "#ffffff", background: "#0f3b3e", padding: { left: 1, right: 1 } };
|
|
1250
|
+
const ERROR_STYLE = { color: "#fecaca" };
|
|
1251
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
1252
|
+
|
|
1253
|
+
function shouldRunSnapshot() {
|
|
1254
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function createProfileForm(onSaved: (profile: SavedProfile) => void) {
|
|
1258
|
+
return new FormStore<ProfileForm>({
|
|
1259
|
+
state: { name: "", email: "" },
|
|
1260
|
+
schema: {
|
|
1261
|
+
type: "object",
|
|
1262
|
+
properties: {
|
|
1263
|
+
name: { type: "string", minLength: 1 },
|
|
1264
|
+
email: { type: "string", format: "email" }
|
|
1265
|
+
},
|
|
1266
|
+
required: ["name", "email"]
|
|
1267
|
+
},
|
|
1268
|
+
clean: {
|
|
1269
|
+
name: (value) => String(value).trim(),
|
|
1270
|
+
email: (value) => String(value).trim().toLowerCase()
|
|
1271
|
+
},
|
|
1272
|
+
onSubmit(values) {
|
|
1273
|
+
onSaved({ name: values.name, email: values.email });
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function fieldError(form: FormStore<ProfileForm>, field: keyof ProfileForm) {
|
|
1279
|
+
return String(form.validationErrors[field] || "not checked");
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function savedSummary(profile: SavedProfile | null) {
|
|
1283
|
+
return profile ? `${profile.name} / ${profile.email}` : "none";
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
export function App({ state, onSubmit }: { state: FormDemoState; onSubmit: () => void }) {
|
|
1287
|
+
return (
|
|
1288
|
+
<Screen title="Profile Editor">
|
|
1289
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Profile Editor</Text>
|
|
1290
|
+
<Pane style={FIELD_STYLE}>
|
|
1291
|
+
<Text>Name</Text>
|
|
1292
|
+
<Input
|
|
1293
|
+
id="profile-name"
|
|
1294
|
+
value={state.form.state.name}
|
|
1295
|
+
placeholder="Full name"
|
|
1296
|
+
onchange={(event) => {
|
|
1297
|
+
state.form.setField("name", event.value);
|
|
1298
|
+
state.valid = false;
|
|
1299
|
+
}}
|
|
1300
|
+
onsubmit={onSubmit}
|
|
1301
|
+
/>
|
|
1302
|
+
<Text>{`Name: ${state.form.state.name || "blank"}`}</Text>
|
|
1303
|
+
<Text style={ERROR_STYLE}>{`Name error: ${fieldError(state.form, "name")}`}</Text>
|
|
1304
|
+
<Text>Email</Text>
|
|
1305
|
+
<Input
|
|
1306
|
+
id="profile-email"
|
|
1307
|
+
value={state.form.state.email}
|
|
1308
|
+
placeholder="name@example.com"
|
|
1309
|
+
onchange={(event) => {
|
|
1310
|
+
state.form.setField("email", event.value);
|
|
1311
|
+
state.valid = false;
|
|
1312
|
+
}}
|
|
1313
|
+
onsubmit={onSubmit}
|
|
1314
|
+
/>
|
|
1315
|
+
<Text>{`Email: ${state.form.state.email || "blank"}`}</Text>
|
|
1316
|
+
<Text style={ERROR_STYLE}>{`Email error: ${fieldError(state.form, "email")}`}</Text>
|
|
1317
|
+
</Pane>
|
|
1318
|
+
<Pane style={PANEL_STYLE}>
|
|
1319
|
+
<Text>{`Form valid: ${state.valid}`}</Text>
|
|
1320
|
+
<Text>{`Submit: ${state.submitMessage}`}</Text>
|
|
1321
|
+
<Text>{`Reset: ${state.resetMessage}`}</Text>
|
|
1322
|
+
<Text>{`Saved profile: ${savedSummary(state.savedProfile)}`}</Text>
|
|
1323
|
+
</Pane>
|
|
1324
|
+
<Text style={FOOTER_STYLE}>Type profile fields | Tab: next field | Shift+Tab: previous field | Enter: save profile | R: reset form | Ctrl+C: quit</Text>
|
|
1325
|
+
</Screen>
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
export function createModuleFormsDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
1330
|
+
const state: FormDemoState = {
|
|
1331
|
+
form: null as unknown as FormStore<ProfileForm>,
|
|
1332
|
+
valid: false,
|
|
1333
|
+
savedProfile: null,
|
|
1334
|
+
submitMessage: "not submitted",
|
|
1335
|
+
resetMessage: "not reset",
|
|
1336
|
+
running: true
|
|
1337
|
+
};
|
|
1338
|
+
state.form = createProfileForm((profile) => {
|
|
1339
|
+
state.savedProfile = profile;
|
|
1340
|
+
state.submitMessage = `saved ${profile.name} <${profile.email}>`;
|
|
1341
|
+
});
|
|
1342
|
+
let session: TerminalSession;
|
|
1343
|
+
|
|
1344
|
+
function resetForm() {
|
|
1345
|
+
state.form.reset();
|
|
1346
|
+
state.valid = false;
|
|
1347
|
+
state.savedProfile = null;
|
|
1348
|
+
state.submitMessage = "not submitted";
|
|
1349
|
+
state.resetMessage = "form reset";
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function submitForm() {
|
|
1353
|
+
state.valid = state.form.validate();
|
|
1354
|
+
if (!state.valid) {
|
|
1355
|
+
state.submitMessage = "blocked by validation";
|
|
1356
|
+
session.update();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
void state.form.submit().then((ok) => {
|
|
1361
|
+
state.valid = ok;
|
|
1362
|
+
if (!ok) state.submitMessage = "blocked by validation";
|
|
1363
|
+
session.update();
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function quit() {
|
|
1368
|
+
state.running = false;
|
|
1369
|
+
session.destroy();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
session = mountTerminal(<App state={state} onSubmit={submitForm} />, {
|
|
1373
|
+
...options,
|
|
1374
|
+
cols: options.cols ?? 92,
|
|
1375
|
+
rows: options.rows ?? 20,
|
|
1376
|
+
keymap: {
|
|
1377
|
+
...options.keymap,
|
|
1378
|
+
bindings: [
|
|
1379
|
+
...(options.keymap?.bindings || []),
|
|
1380
|
+
{ key: "R", command: { id: "forms.reset" }, scope: "global" },
|
|
1381
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
1382
|
+
],
|
|
1383
|
+
onCommand(command, context) {
|
|
1384
|
+
if (command.id === "forms.reset") {
|
|
1385
|
+
resetForm();
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
if (command.id === "quit") {
|
|
1389
|
+
quit();
|
|
1390
|
+
return true;
|
|
1391
|
+
}
|
|
1392
|
+
return options.keymap?.onCommand?.(command, context);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
session.focus("profile-name");
|
|
1398
|
+
|
|
1399
|
+
return {
|
|
1400
|
+
session,
|
|
1401
|
+
dispatchKey(key: string) {
|
|
1402
|
+
return session.dispatchKey(key);
|
|
1403
|
+
},
|
|
1404
|
+
output() {
|
|
1405
|
+
return session.output();
|
|
1406
|
+
},
|
|
1407
|
+
ansiOutput() {
|
|
1408
|
+
return session.ansiOutput();
|
|
1409
|
+
},
|
|
1410
|
+
isRunning() {
|
|
1411
|
+
return state.running;
|
|
1412
|
+
},
|
|
1413
|
+
destroy() {
|
|
1414
|
+
state.running = false;
|
|
1415
|
+
session.destroy();
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (import.meta.main) {
|
|
1421
|
+
if (shouldRunSnapshot()) {
|
|
1422
|
+
const demo = createModuleFormsDemo({ runtime: "headless", cols: 92, rows: 20 });
|
|
1423
|
+
demo.dispatchKey("ENTER");
|
|
1424
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1425
|
+
for (const key of "AdaLovelace") demo.dispatchKey(key);
|
|
1426
|
+
demo.dispatchKey("TAB");
|
|
1427
|
+
for (const key of "ada@example.com") demo.dispatchKey(key);
|
|
1428
|
+
demo.dispatchKey("ENTER");
|
|
1429
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1430
|
+
process.stdout.write(demo.output());
|
|
1431
|
+
process.stdout.write("\n");
|
|
1432
|
+
demo.destroy();
|
|
1433
|
+
} else {
|
|
1434
|
+
createModuleFormsDemo();
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
See the Valyrian.js documentation for the full `valyrian.js/forms` API.
|
|
1440
|
+
|
|
1441
|
+
### `valyrian.js/native-store`
|
|
1442
|
+
|
|
1443
|
+
Persist local settings with `createNativeStore`, `set(key, value)`, and `get(key)` for values shared across renders. Demo app: **Persistent Workspace Settings**.
|
|
1444
|
+
|
|
1445
|
+
Complete demo: **Persistent Workspace Settings** ([`examples/docs/module-native-store.tsx`](../examples/docs/module-native-store.tsx)). Run it with `bun examples/docs/module-native-store.tsx`, type the workspace, save with `Enter`, reset with `R`, and quit with `Ctrl+C`.
|
|
1446
|
+
|
|
1447
|
+
Complete runnable example:
|
|
1448
|
+
|
|
1449
|
+
```tsx
|
|
1450
|
+
import { createNativeStore, StorageType } from "valyrian.js/native-store";
|
|
1451
|
+
import { Input, Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
1452
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
1453
|
+
|
|
1454
|
+
interface ModuleDemo {
|
|
1455
|
+
session: TerminalSession;
|
|
1456
|
+
dispatchKey(key: string): string;
|
|
1457
|
+
output(): string;
|
|
1458
|
+
ansiOutput(): string;
|
|
1459
|
+
isRunning(): boolean;
|
|
1460
|
+
destroy(): void;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
type NativeStoreDemoOptions = TerminalMountOptions & {
|
|
1464
|
+
storeKey?: string;
|
|
1465
|
+
resetOnStart?: boolean;
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
interface WorkspaceSettingsState {
|
|
1469
|
+
draftWorkspace: string;
|
|
1470
|
+
restoredWorkspace: string;
|
|
1471
|
+
savedWorkspace: string;
|
|
1472
|
+
status: string;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const DEFAULT_WORKSPACE = "Northwind CLI";
|
|
1476
|
+
const DEFAULT_STORE_KEY = "valyrian-terminal-docs.workspace-settings";
|
|
1477
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
1478
|
+
const FIELD_STYLE = { color: "#ffffff", background: "#0f3b3e", padding: { left: 1, right: 1 } };
|
|
1479
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
1480
|
+
|
|
1481
|
+
function shouldRunSnapshot() {
|
|
1482
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
function normalizeWorkspace(value: unknown) {
|
|
1487
|
+
const workspace = String(value ?? "").trim();
|
|
1488
|
+
return workspace.length > 0 ? workspace : DEFAULT_WORKSPACE;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export function App({ state, onSave }: { state: WorkspaceSettingsState; onSave: () => void }) {
|
|
1492
|
+
return (
|
|
1493
|
+
<Screen title="Persistent Workspace Settings">
|
|
1494
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Persistent Workspace Settings</Text>
|
|
1495
|
+
<Pane style={FIELD_STYLE}>
|
|
1496
|
+
<Text>Workspace name</Text>
|
|
1497
|
+
<Input
|
|
1498
|
+
id="workspace-name"
|
|
1499
|
+
value={state.draftWorkspace}
|
|
1500
|
+
placeholder="Workspace name"
|
|
1501
|
+
onchange={(event) => {
|
|
1502
|
+
state.draftWorkspace = event.value;
|
|
1503
|
+
state.status = "editing workspace setting";
|
|
1504
|
+
}}
|
|
1505
|
+
onsubmit={onSave}
|
|
1506
|
+
/>
|
|
1507
|
+
<Text>{`Draft workspace: ${state.draftWorkspace || "blank"}`}</Text>
|
|
1508
|
+
</Pane>
|
|
1509
|
+
<Pane style={PANEL_STYLE}>
|
|
1510
|
+
<Text>{`Restored workspace: ${state.restoredWorkspace}`}</Text>
|
|
1511
|
+
<Text>{`Saved workspace: ${state.savedWorkspace}`}</Text>
|
|
1512
|
+
<Text>{`Settings status: ${state.status}`}</Text>
|
|
1513
|
+
<Text>Local native-store keeps workspace settings outside the renderer.</Text>
|
|
1514
|
+
</Pane>
|
|
1515
|
+
<Text style={FOOTER_STYLE}>Type workspace | Enter: save setting | R: reset setting | Ctrl+C: quit</Text>
|
|
1516
|
+
</Screen>
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
export function createModuleNativeStoreDemo(options: NativeStoreDemoOptions = {}): ModuleDemo {
|
|
1521
|
+
const settings = createNativeStore<{ workspace: string }>(options.storeKey ?? DEFAULT_STORE_KEY, {}, StorageType.Session, true);
|
|
1522
|
+
if (options.resetOnStart) {
|
|
1523
|
+
settings.clear();
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function loadWorkspace() {
|
|
1527
|
+
const restored = normalizeWorkspace(settings.get("workspace"));
|
|
1528
|
+
if (settings.get("workspace") !== restored) settings.set("workspace", restored);
|
|
1529
|
+
return restored;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const restoredWorkspace = loadWorkspace();
|
|
1533
|
+
const state: WorkspaceSettingsState = {
|
|
1534
|
+
draftWorkspace: restoredWorkspace,
|
|
1535
|
+
restoredWorkspace,
|
|
1536
|
+
savedWorkspace: restoredWorkspace,
|
|
1537
|
+
status: "loaded saved setting"
|
|
1538
|
+
};
|
|
1539
|
+
let running = true;
|
|
1540
|
+
let session: TerminalSession;
|
|
1541
|
+
|
|
1542
|
+
function saveWorkspace() {
|
|
1543
|
+
const workspace = normalizeWorkspace(state.draftWorkspace);
|
|
1544
|
+
settings.set("workspace", workspace);
|
|
1545
|
+
state.draftWorkspace = workspace;
|
|
1546
|
+
state.savedWorkspace = workspace;
|
|
1547
|
+
state.restoredWorkspace = workspace;
|
|
1548
|
+
state.status = "saved setting";
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function reloadWorkspace() {
|
|
1552
|
+
settings.load();
|
|
1553
|
+
const workspace = loadWorkspace();
|
|
1554
|
+
state.draftWorkspace = workspace;
|
|
1555
|
+
state.savedWorkspace = workspace;
|
|
1556
|
+
state.restoredWorkspace = workspace;
|
|
1557
|
+
state.status = "loaded saved setting";
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function resetWorkspace() {
|
|
1561
|
+
settings.clear();
|
|
1562
|
+
settings.set("workspace", DEFAULT_WORKSPACE);
|
|
1563
|
+
state.draftWorkspace = "";
|
|
1564
|
+
state.savedWorkspace = DEFAULT_WORKSPACE;
|
|
1565
|
+
state.restoredWorkspace = DEFAULT_WORKSPACE;
|
|
1566
|
+
state.status = "reset to default";
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function quit() {
|
|
1570
|
+
running = false;
|
|
1571
|
+
settings.cleanup();
|
|
1572
|
+
session.destroy();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
session = mountTerminal(<App state={state} onSave={saveWorkspace} />, {
|
|
1576
|
+
...options,
|
|
1577
|
+
cols: options.cols ?? 92,
|
|
1578
|
+
rows: options.rows ?? 18,
|
|
1579
|
+
keymap: {
|
|
1580
|
+
...options.keymap,
|
|
1581
|
+
bindings: [
|
|
1582
|
+
...(options.keymap?.bindings || []),
|
|
1583
|
+
{ key: "R", command: { id: "store.reset" }, scope: "global" },
|
|
1584
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
1585
|
+
],
|
|
1586
|
+
onCommand(command, context) {
|
|
1587
|
+
if (command.id === "store.reset") {
|
|
1588
|
+
resetWorkspace();
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
if (command.id === "quit") {
|
|
1592
|
+
quit();
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
return options.keymap?.onCommand?.(command, context);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
session.focus("workspace-name");
|
|
1601
|
+
|
|
1602
|
+
return {
|
|
1603
|
+
session,
|
|
1604
|
+
dispatchKey(key: string) {
|
|
1605
|
+
return session.dispatchKey(key);
|
|
1606
|
+
},
|
|
1607
|
+
output() {
|
|
1608
|
+
return session.output();
|
|
1609
|
+
},
|
|
1610
|
+
ansiOutput() {
|
|
1611
|
+
return session.ansiOutput();
|
|
1612
|
+
},
|
|
1613
|
+
isRunning() {
|
|
1614
|
+
return running;
|
|
1615
|
+
},
|
|
1616
|
+
destroy() {
|
|
1617
|
+
running = false;
|
|
1618
|
+
settings.cleanup();
|
|
1619
|
+
session.destroy();
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
if (import.meta.main) {
|
|
1625
|
+
if (shouldRunSnapshot()) {
|
|
1626
|
+
const demo = createModuleNativeStoreDemo({ runtime: "headless", cols: 92, rows: 18, resetOnStart: true });
|
|
1627
|
+
demo.dispatchKey("R");
|
|
1628
|
+
demo.session.focus("workspace-name");
|
|
1629
|
+
for (const key of "Support CLI") demo.dispatchKey(key);
|
|
1630
|
+
demo.dispatchKey("ENTER");
|
|
1631
|
+
process.stdout.write(demo.output());
|
|
1632
|
+
process.stdout.write("\n");
|
|
1633
|
+
demo.destroy();
|
|
1634
|
+
} else {
|
|
1635
|
+
createModuleNativeStoreDemo();
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1640
|
+
See the Valyrian.js documentation for the full `valyrian.js/native-store` API.
|
|
1641
|
+
|
|
1642
|
+
### `valyrian.js/translate`
|
|
1643
|
+
|
|
1644
|
+
Localize user-facing labels in terminal screens. Demo app: **Bilingual Support Console**.
|
|
1645
|
+
|
|
1646
|
+
Complete demo: **Bilingual Support Console** ([`examples/docs/module-translate.tsx`](../examples/docs/module-translate.tsx)). Run it with `bun examples/docs/module-translate.tsx`, switch language with `L`, select tickets with `J/K`, filter status with `F`, and quit with `Ctrl+C`.
|
|
1647
|
+
|
|
1648
|
+
Complete runnable example:
|
|
1649
|
+
|
|
1650
|
+
```tsx
|
|
1651
|
+
import { getLang, setLang, setLog, setStoreStrategy, setTranslations, t } from "valyrian.js/translate";
|
|
1652
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
1653
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
1654
|
+
|
|
1655
|
+
interface ModuleDemo {
|
|
1656
|
+
session: TerminalSession;
|
|
1657
|
+
dispatchKey(key: string): string;
|
|
1658
|
+
output(): string;
|
|
1659
|
+
ansiOutput(): string;
|
|
1660
|
+
isRunning(): boolean;
|
|
1661
|
+
destroy(): void;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
type Ticket = { id: string; customer: string; status: "open" | "waiting" | "closed" };
|
|
1665
|
+
type SupportState = { selected: number; language: "en" | "es"; filter: "all" | "open" };
|
|
1666
|
+
|
|
1667
|
+
const TICKETS: Ticket[] = [
|
|
1668
|
+
{ id: "T-104", customer: "Northwind", status: "open" },
|
|
1669
|
+
{ id: "T-118", customer: "Contoso", status: "waiting" },
|
|
1670
|
+
{ id: "T-130", customer: "Tailspin", status: "closed" }
|
|
1671
|
+
];
|
|
1672
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
1673
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
1674
|
+
let storedLanguage = "en";
|
|
1675
|
+
|
|
1676
|
+
function configureTranslations() {
|
|
1677
|
+
setLog(false);
|
|
1678
|
+
setStoreStrategy({
|
|
1679
|
+
get: () => storedLanguage,
|
|
1680
|
+
set: (language) => {
|
|
1681
|
+
storedLanguage = language;
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
setTranslations(
|
|
1685
|
+
{
|
|
1686
|
+
title: "Bilingual Support Console",
|
|
1687
|
+
purpose: "Support Console",
|
|
1688
|
+
language: "Language",
|
|
1689
|
+
filter: "Filter",
|
|
1690
|
+
selected: "Selected ticket",
|
|
1691
|
+
all: "all tickets",
|
|
1692
|
+
openOnly: "open only",
|
|
1693
|
+
status: { open: "open", waiting: "waiting", closed: "closed" },
|
|
1694
|
+
help: "L language J/K select ticket F filter status Ctrl+C: quit"
|
|
1695
|
+
},
|
|
1696
|
+
{
|
|
1697
|
+
es: {
|
|
1698
|
+
title: "Bilingual Support Console",
|
|
1699
|
+
purpose: "Consola de soporte",
|
|
1700
|
+
language: "Idioma",
|
|
1701
|
+
filter: "Filtro",
|
|
1702
|
+
selected: "Ticket elegido",
|
|
1703
|
+
all: "todos los tickets",
|
|
1704
|
+
openOnly: "solo abiertos",
|
|
1705
|
+
status: { open: "abierto", waiting: "en espera", closed: "cerrado" },
|
|
1706
|
+
help: "L idioma J/K elegir ticket F filtrar estado Ctrl+C: quit"
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function shouldRunSnapshot() {
|
|
1713
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function visibleTickets(filter: SupportState["filter"]) {
|
|
1717
|
+
return filter === "open" ? TICKETS.filter((ticket) => ticket.status === "open") : TICKETS;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
export function App({ state }: { state: SupportState }) {
|
|
1721
|
+
setLang(state.language);
|
|
1722
|
+
const tickets = visibleTickets(state.filter);
|
|
1723
|
+
const selected = tickets[state.selected] || tickets[0];
|
|
1724
|
+
|
|
1725
|
+
return (
|
|
1726
|
+
<Screen title="Bilingual Support Console">
|
|
1727
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>{t("title")}</Text>
|
|
1728
|
+
<Pane style={PANEL_STYLE}>
|
|
1729
|
+
<Text>{t("purpose")}</Text>
|
|
1730
|
+
<Text>{`${t("language")}: ${state.language === "en" ? "English" : "Español"}`}</Text>
|
|
1731
|
+
<Text>{`${t("filter")}: ${state.filter === "open" ? t("openOnly") : t("all")}`}</Text>
|
|
1732
|
+
{tickets.map((ticket, index) => (
|
|
1733
|
+
<Text>{`${index === state.selected ? ">" : " "} ${ticket.id} ${ticket.customer} — ${t(`status.${ticket.status}`)}`}</Text>
|
|
1734
|
+
))}
|
|
1735
|
+
<Text>{`${t("selected")}: ${selected.id} ${selected.customer}`}</Text>
|
|
1736
|
+
</Pane>
|
|
1737
|
+
<Text style={FOOTER_STYLE}>{t("help")}</Text>
|
|
1738
|
+
</Screen>
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
export function createModuleTranslateDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
1743
|
+
const previousLanguage = getLang();
|
|
1744
|
+
configureTranslations();
|
|
1745
|
+
const state: SupportState = { selected: 0, language: "en", filter: "all" };
|
|
1746
|
+
setLang(state.language);
|
|
1747
|
+
let running = true;
|
|
1748
|
+
let session: TerminalSession;
|
|
1749
|
+
|
|
1750
|
+
function quit() {
|
|
1751
|
+
running = false;
|
|
1752
|
+
setLang(previousLanguage);
|
|
1753
|
+
session.destroy();
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function clampSelection() {
|
|
1757
|
+
const count = visibleTickets(state.filter).length;
|
|
1758
|
+
state.selected = Math.min(state.selected, Math.max(0, count - 1));
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function move(delta: number) {
|
|
1762
|
+
const count = visibleTickets(state.filter).length;
|
|
1763
|
+
state.selected = (state.selected + delta + count) % count;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
session = mountTerminal(<App state={state} />, {
|
|
1767
|
+
...options,
|
|
1768
|
+
cols: options.cols ?? 92,
|
|
1769
|
+
rows: options.rows ?? 18,
|
|
1770
|
+
keymap: {
|
|
1771
|
+
...options.keymap,
|
|
1772
|
+
bindings: [
|
|
1773
|
+
...(options.keymap?.bindings || []),
|
|
1774
|
+
{ key: "l", command: { id: "support.language" }, scope: "global" },
|
|
1775
|
+
{ key: "L", command: { id: "support.language" }, scope: "global" },
|
|
1776
|
+
{ key: "j", command: { id: "support.next" }, scope: "global" },
|
|
1777
|
+
{ key: "J", command: { id: "support.next" }, scope: "global" },
|
|
1778
|
+
{ key: "k", command: { id: "support.previous" }, scope: "global" },
|
|
1779
|
+
{ key: "K", command: { id: "support.previous" }, scope: "global" },
|
|
1780
|
+
{ key: "f", command: { id: "support.filter" }, scope: "global" },
|
|
1781
|
+
{ key: "F", command: { id: "support.filter" }, scope: "global" },
|
|
1782
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
1783
|
+
],
|
|
1784
|
+
onCommand(command, context) {
|
|
1785
|
+
if (command.id === "support.language") {
|
|
1786
|
+
state.language = state.language === "en" ? "es" : "en";
|
|
1787
|
+
setLang(state.language);
|
|
1788
|
+
session.update();
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
if (command.id === "support.next") {
|
|
1792
|
+
move(1);
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
if (command.id === "support.previous") {
|
|
1796
|
+
move(-1);
|
|
1797
|
+
return true;
|
|
1798
|
+
}
|
|
1799
|
+
if (command.id === "support.filter") {
|
|
1800
|
+
state.filter = state.filter === "all" ? "open" : "all";
|
|
1801
|
+
clampSelection();
|
|
1802
|
+
return true;
|
|
1803
|
+
}
|
|
1804
|
+
if (command.id === "quit") {
|
|
1805
|
+
quit();
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
return options.keymap?.onCommand?.(command, context);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
return {
|
|
1814
|
+
session,
|
|
1815
|
+
dispatchKey(key: string) {
|
|
1816
|
+
return session.dispatchKey(key);
|
|
1817
|
+
},
|
|
1818
|
+
output() {
|
|
1819
|
+
return session.output();
|
|
1820
|
+
},
|
|
1821
|
+
ansiOutput() {
|
|
1822
|
+
return session.ansiOutput();
|
|
1823
|
+
},
|
|
1824
|
+
isRunning() {
|
|
1825
|
+
return running;
|
|
1826
|
+
},
|
|
1827
|
+
destroy() {
|
|
1828
|
+
running = false;
|
|
1829
|
+
setLang(previousLanguage);
|
|
1830
|
+
session.destroy();
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
if (import.meta.main) {
|
|
1836
|
+
if (shouldRunSnapshot()) {
|
|
1837
|
+
const demo = createModuleTranslateDemo({ runtime: "headless", cols: 92, rows: 18 });
|
|
1838
|
+
process.stdout.write(demo.output());
|
|
1839
|
+
process.stdout.write("\n");
|
|
1840
|
+
demo.destroy();
|
|
1841
|
+
} else {
|
|
1842
|
+
createModuleTranslateDemo();
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
```
|
|
1846
|
+
|
|
1847
|
+
See the Valyrian.js documentation for the full `valyrian.js/translate` API.
|
|
1848
|
+
|
|
1849
|
+
### `valyrian.js/money`
|
|
1850
|
+
|
|
1851
|
+
Format currency totals with precise cents and locale-aware display. Demo app: **Invoice Calculator**.
|
|
1852
|
+
|
|
1853
|
+
Complete demo: **Invoice Calculator** ([`examples/docs/module-money.tsx`](../examples/docs/module-money.tsx)). Run it with `bun examples/docs/module-money.tsx`, select lines with `J/K`, change quantity with `+/-`, toggle discount with `D`, switch locale with `L`, and quit with `Ctrl+C`.
|
|
1854
|
+
|
|
1855
|
+
Complete runnable example:
|
|
1856
|
+
|
|
1857
|
+
```tsx
|
|
1858
|
+
import { Money, formatMoney, parseMoneyInput } from "valyrian.js/money";
|
|
1859
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
1860
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
1861
|
+
|
|
1862
|
+
interface ModuleDemo {
|
|
1863
|
+
session: TerminalSession;
|
|
1864
|
+
dispatchKey(key: string): string;
|
|
1865
|
+
output(): string;
|
|
1866
|
+
ansiOutput(): string;
|
|
1867
|
+
isRunning(): boolean;
|
|
1868
|
+
destroy(): void;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
type InvoiceLine = { name: string; unit: Money; quantity: number };
|
|
1872
|
+
type InvoiceState = { selected: number; discount: boolean; localeIndex: number; lines: InvoiceLine[] };
|
|
1873
|
+
|
|
1874
|
+
const LOCALES = [
|
|
1875
|
+
{ label: "US / USD", locale: "en-US", currency: "USD" },
|
|
1876
|
+
{ label: "MX / USD", locale: "es-MX", currency: "USD" }
|
|
1877
|
+
] as const;
|
|
1878
|
+
const INITIAL_LINES: InvoiceLine[] = [
|
|
1879
|
+
{ name: "Support seats", unit: parseMoneyInput("49.00", { locale: "en-US" }), quantity: 3 },
|
|
1880
|
+
{ name: "Priority add-on", unit: Money.fromDecimal(120), quantity: 1 },
|
|
1881
|
+
{ name: "Storage pack", unit: Money.fromCents(2500), quantity: 2 }
|
|
1882
|
+
];
|
|
1883
|
+
const TAX_RATE = 0.16;
|
|
1884
|
+
const DISCOUNT_RATE = 0.1;
|
|
1885
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
1886
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
1887
|
+
|
|
1888
|
+
function shouldRunSnapshot() {
|
|
1889
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function cloneLines() {
|
|
1893
|
+
return INITIAL_LINES.map((line) => ({ ...line }));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function moneyView(value: Money, state: InvoiceState) {
|
|
1897
|
+
const locale = LOCALES[state.localeIndex];
|
|
1898
|
+
return formatMoney(value, { currency: locale.currency, locale: locale.locale, digits: 2 });
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function invoiceTotals(state: InvoiceState) {
|
|
1902
|
+
const subtotal = state.lines.reduce((total, line) => total.add(line.unit.multiply(line.quantity)), Money.fromCents(0));
|
|
1903
|
+
const discount = state.discount ? subtotal.multiply(DISCOUNT_RATE) : Money.fromCents(0);
|
|
1904
|
+
const taxable = subtotal.subtract(discount);
|
|
1905
|
+
const tax = taxable.multiply(TAX_RATE);
|
|
1906
|
+
const total = taxable.add(tax);
|
|
1907
|
+
return { subtotal, discount, tax, total };
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
export function App({ state }: { state: InvoiceState }) {
|
|
1911
|
+
const totals = invoiceTotals(state);
|
|
1912
|
+
const locale = LOCALES[state.localeIndex];
|
|
1913
|
+
|
|
1914
|
+
return (
|
|
1915
|
+
<Screen title="Invoice Calculator">
|
|
1916
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Invoice Calculator</Text>
|
|
1917
|
+
<Pane style={PANEL_STYLE}>
|
|
1918
|
+
<Text>Invoice Calculator</Text>
|
|
1919
|
+
<Text>{`Locale: ${locale.label}`}</Text>
|
|
1920
|
+
{state.lines.map((line, index) => (
|
|
1921
|
+
<Text>{`${index === state.selected ? ">" : " "} ${line.name} x${line.quantity} — ${moneyView(line.unit.multiply(line.quantity), state)}`}</Text>
|
|
1922
|
+
))}
|
|
1923
|
+
<Text>{`Subtotal: ${moneyView(totals.subtotal, state)}`}</Text>
|
|
1924
|
+
<Text>{`Discount: ${state.discount ? moneyView(totals.discount, state) : "none"}`}</Text>
|
|
1925
|
+
<Text>{`Tax: ${moneyView(totals.tax, state)}`}</Text>
|
|
1926
|
+
<Text>{`Total: ${moneyView(totals.total, state)}`}</Text>
|
|
1927
|
+
</Pane>
|
|
1928
|
+
<Text style={FOOTER_STYLE}>J/K line +/- quantity D discount L locale Ctrl+C: quit</Text>
|
|
1929
|
+
</Screen>
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
export function createModuleMoneyDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
1934
|
+
const state: InvoiceState = { selected: 0, discount: false, localeIndex: 0, lines: cloneLines() };
|
|
1935
|
+
let running = true;
|
|
1936
|
+
let session: TerminalSession;
|
|
1937
|
+
|
|
1938
|
+
function quit() {
|
|
1939
|
+
running = false;
|
|
1940
|
+
session.destroy();
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function changeQuantity(delta: number) {
|
|
1944
|
+
const line = state.lines[state.selected];
|
|
1945
|
+
line.quantity = Math.max(0, line.quantity + delta);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
session = mountTerminal(<App state={state} />, {
|
|
1949
|
+
...options,
|
|
1950
|
+
cols: options.cols ?? 92,
|
|
1951
|
+
rows: options.rows ?? 20,
|
|
1952
|
+
keymap: {
|
|
1953
|
+
...options.keymap,
|
|
1954
|
+
bindings: [
|
|
1955
|
+
...(options.keymap?.bindings || []),
|
|
1956
|
+
{ key: "j", command: { id: "invoice.next" }, scope: "global" },
|
|
1957
|
+
{ key: "J", command: { id: "invoice.next" }, scope: "global" },
|
|
1958
|
+
{ key: "k", command: { id: "invoice.previous" }, scope: "global" },
|
|
1959
|
+
{ key: "K", command: { id: "invoice.previous" }, scope: "global" },
|
|
1960
|
+
{ key: "+", command: { id: "invoice.increase" }, scope: "global" },
|
|
1961
|
+
{ key: "-", command: { id: "invoice.decrease" }, scope: "global" },
|
|
1962
|
+
{ key: "d", command: { id: "invoice.discount" }, scope: "global" },
|
|
1963
|
+
{ key: "D", command: { id: "invoice.discount" }, scope: "global" },
|
|
1964
|
+
{ key: "l", command: { id: "invoice.locale" }, scope: "global" },
|
|
1965
|
+
{ key: "L", command: { id: "invoice.locale" }, scope: "global" },
|
|
1966
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
1967
|
+
],
|
|
1968
|
+
onCommand(command, context) {
|
|
1969
|
+
if (command.id === "invoice.next") {
|
|
1970
|
+
state.selected = (state.selected + 1) % state.lines.length;
|
|
1971
|
+
return true;
|
|
1972
|
+
}
|
|
1973
|
+
if (command.id === "invoice.previous") {
|
|
1974
|
+
state.selected = (state.selected - 1 + state.lines.length) % state.lines.length;
|
|
1975
|
+
return true;
|
|
1976
|
+
}
|
|
1977
|
+
if (command.id === "invoice.increase") {
|
|
1978
|
+
changeQuantity(1);
|
|
1979
|
+
return true;
|
|
1980
|
+
}
|
|
1981
|
+
if (command.id === "invoice.decrease") {
|
|
1982
|
+
changeQuantity(-1);
|
|
1983
|
+
return true;
|
|
1984
|
+
}
|
|
1985
|
+
if (command.id === "invoice.discount") {
|
|
1986
|
+
state.discount = !state.discount;
|
|
1987
|
+
return true;
|
|
1988
|
+
}
|
|
1989
|
+
if (command.id === "invoice.locale") {
|
|
1990
|
+
state.localeIndex = (state.localeIndex + 1) % LOCALES.length;
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
if (command.id === "quit") {
|
|
1994
|
+
quit();
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
return options.keymap?.onCommand?.(command, context);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
return {
|
|
2003
|
+
session,
|
|
2004
|
+
dispatchKey(key: string) {
|
|
2005
|
+
return session.dispatchKey(key);
|
|
2006
|
+
},
|
|
2007
|
+
output() {
|
|
2008
|
+
return session.output();
|
|
2009
|
+
},
|
|
2010
|
+
ansiOutput() {
|
|
2011
|
+
return session.ansiOutput();
|
|
2012
|
+
},
|
|
2013
|
+
isRunning() {
|
|
2014
|
+
return running;
|
|
2015
|
+
},
|
|
2016
|
+
destroy() {
|
|
2017
|
+
running = false;
|
|
2018
|
+
session.destroy();
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
if (import.meta.main) {
|
|
2024
|
+
if (shouldRunSnapshot()) {
|
|
2025
|
+
const demo = createModuleMoneyDemo({ runtime: "headless", cols: 92, rows: 20 });
|
|
2026
|
+
process.stdout.write(demo.output());
|
|
2027
|
+
process.stdout.write("\n");
|
|
2028
|
+
demo.destroy();
|
|
2029
|
+
} else {
|
|
2030
|
+
createModuleMoneyDemo();
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
See the Valyrian.js documentation for the full `valyrian.js/money` API.
|
|
2036
|
+
|
|
2037
|
+
### `valyrian.js/utils`
|
|
2038
|
+
|
|
2039
|
+
Use shaping helpers for object paths and small view models before render. Demo app: **Account Data Inspector**.
|
|
2040
|
+
|
|
2041
|
+
Complete demo: **Account Data Inspector** ([`examples/docs/module-utils.tsx`](../examples/docs/module-utils.tsx)). Run it with `bun examples/docs/module-utils.tsx`, select fields with `J/K`, filter the group with `F`, reset with `R`, and quit with `Ctrl+C`.
|
|
2042
|
+
|
|
2043
|
+
Complete runnable example:
|
|
2044
|
+
|
|
2045
|
+
```tsx
|
|
2046
|
+
import { get, pick, set } from "valyrian.js/utils";
|
|
2047
|
+
import { Pane, Screen, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
2048
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
2049
|
+
|
|
2050
|
+
interface ModuleDemo {
|
|
2051
|
+
session: TerminalSession;
|
|
2052
|
+
dispatchKey(key: string): string;
|
|
2053
|
+
output(): string;
|
|
2054
|
+
ansiOutput(): string;
|
|
2055
|
+
isRunning(): boolean;
|
|
2056
|
+
destroy(): void;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
type AccountPayload = {
|
|
2060
|
+
account: { name: string; plan: string; region?: string; owner: { name: string; email: string } };
|
|
2061
|
+
usage: { seats: number; projects: number; lastLogin: string };
|
|
2062
|
+
billing: { status: string; renewal: string };
|
|
2063
|
+
};
|
|
2064
|
+
type InspectorState = { selected: number; filter: "all" | "account"; record: AccountPayload };
|
|
2065
|
+
type FieldRow = { group: string; label: string; value: string };
|
|
2066
|
+
type AccountSummaryKey = "name" | "plan" | "region";
|
|
2067
|
+
|
|
2068
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
2069
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
2070
|
+
|
|
2071
|
+
function shouldRunSnapshot() {
|
|
2072
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function createRecord(): AccountPayload {
|
|
2076
|
+
const record: AccountPayload = {
|
|
2077
|
+
account: { name: "Northwind", plan: "Team", owner: { name: "Ada Lovelace", email: "ada@example.test" } },
|
|
2078
|
+
usage: { seats: 12, projects: 4, lastLogin: "2026-05-29" },
|
|
2079
|
+
billing: { status: "current", renewal: "2026-06-15" }
|
|
2080
|
+
};
|
|
2081
|
+
set(record, "account.region", "MX");
|
|
2082
|
+
return record;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function buildRows(record: AccountPayload, filter: InspectorState["filter"]): FieldRow[] {
|
|
2086
|
+
const safeAccount = pick<AccountPayload["account"], AccountSummaryKey>(record.account, ["name", "plan", "region"]);
|
|
2087
|
+
const rows = [
|
|
2088
|
+
{ group: "account", label: "Account", value: String(get(safeAccount, "name", "unknown")) },
|
|
2089
|
+
{ group: "account", label: "Plan", value: String(get(safeAccount, "plan", "none")) },
|
|
2090
|
+
{ group: "account", label: "Region", value: String(get(safeAccount, "region", "unknown")) },
|
|
2091
|
+
{ group: "owner", label: "Owner", value: String(get(record, "account.owner.name", "unknown")) },
|
|
2092
|
+
{ group: "usage", label: "Seats", value: String(get(record, "usage.seats", 0)) },
|
|
2093
|
+
{ group: "usage", label: "Projects", value: String(get(record, "usage.projects", 0)) },
|
|
2094
|
+
{ group: "billing", label: "Billing", value: String(get(record, "billing.status", "unknown")) }
|
|
2095
|
+
];
|
|
2096
|
+
return filter === "account" ? rows.filter((row) => row.group === "account") : rows;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
export function App({ state }: { state: InspectorState }) {
|
|
2100
|
+
const rows = buildRows(state.record, state.filter);
|
|
2101
|
+
const selected = rows[state.selected] || rows[0];
|
|
2102
|
+
|
|
2103
|
+
return (
|
|
2104
|
+
<Screen title="Account Data Inspector">
|
|
2105
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Account Data Inspector</Text>
|
|
2106
|
+
<Pane style={PANEL_STYLE}>
|
|
2107
|
+
<Text>Account Data Inspector</Text>
|
|
2108
|
+
<Text>{`Filter: ${state.filter === "account" ? "account fields" : "all groups"}`}</Text>
|
|
2109
|
+
{rows.map((row, index) => (
|
|
2110
|
+
<Text>{`${index === state.selected ? ">" : " "} [${row.group}] ${row.label}: ${row.value}`}</Text>
|
|
2111
|
+
))}
|
|
2112
|
+
<Text>{`Selected field: ${selected.label}`}</Text>
|
|
2113
|
+
</Pane>
|
|
2114
|
+
<Text style={FOOTER_STYLE}>J/K select field F filter group R reset Ctrl+C: quit</Text>
|
|
2115
|
+
</Screen>
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
export function createModuleUtilsDemo(options: TerminalMountOptions = {}): ModuleDemo {
|
|
2120
|
+
const state: InspectorState = { selected: 0, filter: "all", record: createRecord() };
|
|
2121
|
+
let running = true;
|
|
2122
|
+
let session: TerminalSession;
|
|
2123
|
+
|
|
2124
|
+
function quit() {
|
|
2125
|
+
running = false;
|
|
2126
|
+
session.destroy();
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function clampSelection() {
|
|
2130
|
+
const count = buildRows(state.record, state.filter).length;
|
|
2131
|
+
state.selected = Math.min(state.selected, Math.max(0, count - 1));
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
session = mountTerminal(<App state={state} />, {
|
|
2135
|
+
...options,
|
|
2136
|
+
cols: options.cols ?? 92,
|
|
2137
|
+
rows: options.rows ?? 20,
|
|
2138
|
+
keymap: {
|
|
2139
|
+
...options.keymap,
|
|
2140
|
+
bindings: [
|
|
2141
|
+
...(options.keymap?.bindings || []),
|
|
2142
|
+
{ key: "j", command: { id: "inspector.next" }, scope: "global" },
|
|
2143
|
+
{ key: "J", command: { id: "inspector.next" }, scope: "global" },
|
|
2144
|
+
{ key: "k", command: { id: "inspector.previous" }, scope: "global" },
|
|
2145
|
+
{ key: "K", command: { id: "inspector.previous" }, scope: "global" },
|
|
2146
|
+
{ key: "f", command: { id: "inspector.filter" }, scope: "global" },
|
|
2147
|
+
{ key: "F", command: { id: "inspector.filter" }, scope: "global" },
|
|
2148
|
+
{ key: "r", command: { id: "inspector.reset" }, scope: "global" },
|
|
2149
|
+
{ key: "R", command: { id: "inspector.reset" }, scope: "global" },
|
|
2150
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
2151
|
+
],
|
|
2152
|
+
onCommand(command, context) {
|
|
2153
|
+
if (command.id === "inspector.next") {
|
|
2154
|
+
const count = buildRows(state.record, state.filter).length;
|
|
2155
|
+
state.selected = (state.selected + 1) % count;
|
|
2156
|
+
return true;
|
|
2157
|
+
}
|
|
2158
|
+
if (command.id === "inspector.previous") {
|
|
2159
|
+
const count = buildRows(state.record, state.filter).length;
|
|
2160
|
+
state.selected = (state.selected - 1 + count) % count;
|
|
2161
|
+
return true;
|
|
2162
|
+
}
|
|
2163
|
+
if (command.id === "inspector.filter") {
|
|
2164
|
+
state.filter = state.filter === "all" ? "account" : "all";
|
|
2165
|
+
clampSelection();
|
|
2166
|
+
return true;
|
|
2167
|
+
}
|
|
2168
|
+
if (command.id === "inspector.reset") {
|
|
2169
|
+
state.selected = 0;
|
|
2170
|
+
state.filter = "all";
|
|
2171
|
+
state.record = createRecord();
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
if (command.id === "quit") {
|
|
2175
|
+
quit();
|
|
2176
|
+
return true;
|
|
2177
|
+
}
|
|
2178
|
+
return options.keymap?.onCommand?.(command, context);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
return {
|
|
2184
|
+
session,
|
|
2185
|
+
dispatchKey(key: string) {
|
|
2186
|
+
return session.dispatchKey(key);
|
|
2187
|
+
},
|
|
2188
|
+
output() {
|
|
2189
|
+
return session.output();
|
|
2190
|
+
},
|
|
2191
|
+
ansiOutput() {
|
|
2192
|
+
return session.ansiOutput();
|
|
2193
|
+
},
|
|
2194
|
+
isRunning() {
|
|
2195
|
+
return running;
|
|
2196
|
+
},
|
|
2197
|
+
destroy() {
|
|
2198
|
+
running = false;
|
|
2199
|
+
session.destroy();
|
|
2200
|
+
}
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (import.meta.main) {
|
|
2205
|
+
if (shouldRunSnapshot()) {
|
|
2206
|
+
const demo = createModuleUtilsDemo({ runtime: "headless", cols: 92, rows: 20 });
|
|
2207
|
+
process.stdout.write(demo.output());
|
|
2208
|
+
process.stdout.write("\n");
|
|
2209
|
+
demo.destroy();
|
|
2210
|
+
} else {
|
|
2211
|
+
createModuleUtilsDemo();
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
```
|
|
2215
|
+
|
|
2216
|
+
See the Valyrian.js documentation for the full `valyrian.js/utils` API.
|
|
2217
|
+
|
|
2218
|
+
## Composed workflows / integrations
|
|
2219
|
+
|
|
2220
|
+
The workflows below combine modules after the single-module examples. Use them when a terminal app needs more than one app-layer concern on the same screen.
|
|
2221
|
+
|
|
2222
|
+
### Operations Workbench
|
|
2223
|
+
|
|
2224
|
+
Use `valyrian.js`, `valyrian.js/pulses`, and `valyrian.js/flux-store` when a terminal app needs more than local objects glued to key handlers.
|
|
2225
|
+
|
|
2226
|
+
Keyboard events should flow into state transitions. A practical path is: key event -> command or handler -> pulse mutation or flux operation -> render update. Terminal-handled events render after the handler. Timers, promises, file watchers, or external callbacks that change Valyrian reactive state also refresh the terminal when components read that state. Use `session.update()` when the changed data is not reactive or when you need an explicit manual refresh.
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
Complete demo: **Operations Workbench** ([`examples/docs/module-state-workbench.tsx`](../examples/docs/module-state-workbench.tsx)). Run it with `bun examples/docs/module-state-workbench.tsx`, select jobs with `J/K`, dispatch with `Enter`, mark work with `D` or `B`, reset with `R`, and quit with `Ctrl+C`.
|
|
2230
|
+
|
|
2231
|
+
Complete runnable example:
|
|
2232
|
+
|
|
2233
|
+
```tsx
|
|
2234
|
+
import { v } from "valyrian.js";
|
|
2235
|
+
import { createPulse, createPulseStore } from "valyrian.js/pulses";
|
|
2236
|
+
import { FluxStore } from "valyrian.js/flux-store";
|
|
2237
|
+
import { Pane, Screen, Split, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
2238
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
2239
|
+
|
|
2240
|
+
type JobStatus = "queued" | "running" | "done" | "blocked";
|
|
2241
|
+
type OperationAction = "dispatch" | "done" | "block" | "reset";
|
|
2242
|
+
|
|
2243
|
+
interface Job {
|
|
2244
|
+
id: string;
|
|
2245
|
+
label: string;
|
|
2246
|
+
owner: string;
|
|
2247
|
+
status: JobStatus;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
interface OperationEvent {
|
|
2251
|
+
action: OperationAction;
|
|
2252
|
+
job: string;
|
|
2253
|
+
note: string;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
interface ModuleStateWorkbenchDemo {
|
|
2257
|
+
session: TerminalSession;
|
|
2258
|
+
dispatchKey(key: string): string;
|
|
2259
|
+
output(): string;
|
|
2260
|
+
ansiOutput(): string;
|
|
2261
|
+
isRunning(): boolean;
|
|
2262
|
+
destroy(): void;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const INITIAL_JOBS: Job[] = [
|
|
2266
|
+
{ id: "docs", label: "Docs refresh", owner: "Mina", status: "running" },
|
|
2267
|
+
{ id: "release", label: "Package release", owner: "Omar", status: "queued" },
|
|
2268
|
+
{ id: "support", label: "Support sweep", owner: "Iris", status: "blocked" }
|
|
2269
|
+
];
|
|
2270
|
+
|
|
2271
|
+
function createWorkbenchStores() {
|
|
2272
|
+
const jobsStore = createPulseStore(
|
|
2273
|
+
{
|
|
2274
|
+
selectedIndex: 0,
|
|
2275
|
+
jobs: INITIAL_JOBS.map((job) => ({ ...job }))
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
selectNext(state) {
|
|
2279
|
+
state.selectedIndex = (state.selectedIndex + 1) % state.jobs.length;
|
|
2280
|
+
},
|
|
2281
|
+
selectPrevious(state) {
|
|
2282
|
+
state.selectedIndex = (state.selectedIndex - 1 + state.jobs.length) % state.jobs.length;
|
|
2283
|
+
},
|
|
2284
|
+
updateSelectedStatus(state, status: JobStatus) {
|
|
2285
|
+
state.jobs[state.selectedIndex].status = status;
|
|
2286
|
+
},
|
|
2287
|
+
reset(state) {
|
|
2288
|
+
state.selectedIndex = 0;
|
|
2289
|
+
state.jobs = INITIAL_JOBS.map((job) => ({ ...job }));
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
);
|
|
2293
|
+
|
|
2294
|
+
const [pulseRevision, setPulseRevision] = createPulse(1);
|
|
2295
|
+
|
|
2296
|
+
const operations = new FluxStore({
|
|
2297
|
+
state: {
|
|
2298
|
+
done: 0,
|
|
2299
|
+
blocked: 1,
|
|
2300
|
+
dispatched: 0,
|
|
2301
|
+
timeline: ["Bootstrapped queue", "Loaded terminal session"] as string[],
|
|
2302
|
+
lastAction: "dispatch" as OperationAction
|
|
2303
|
+
},
|
|
2304
|
+
mutations: {
|
|
2305
|
+
SET_METRICS(state, jobs: Job[]) {
|
|
2306
|
+
state.done = jobs.filter((job) => job.status === "done").length;
|
|
2307
|
+
state.blocked = jobs.filter((job) => job.status === "blocked").length;
|
|
2308
|
+
},
|
|
2309
|
+
RECORD_EVENT(state, event: OperationEvent) {
|
|
2310
|
+
state.lastAction = event.action;
|
|
2311
|
+
state.dispatched += event.action === "dispatch" ? 1 : 0;
|
|
2312
|
+
state.timeline.push(`${event.action}: ${event.job} — ${event.note}`);
|
|
2313
|
+
state.timeline = state.timeline.slice(-5);
|
|
2314
|
+
},
|
|
2315
|
+
RESET(state) {
|
|
2316
|
+
state.done = 0;
|
|
2317
|
+
state.blocked = 1;
|
|
2318
|
+
state.dispatched = 0;
|
|
2319
|
+
state.lastAction = "reset";
|
|
2320
|
+
state.timeline = ["reset: queue — restored baseline"];
|
|
2321
|
+
}
|
|
2322
|
+
},
|
|
2323
|
+
actions: {
|
|
2324
|
+
async dispatchSelected(store, job: Job) {
|
|
2325
|
+
jobsStore.updateSelectedStatus("running");
|
|
2326
|
+
store.commit("SET_METRICS", jobsStore.state.jobs);
|
|
2327
|
+
store.commit("RECORD_EVENT", { action: "dispatch", job: job.label, note: "operator started work" });
|
|
2328
|
+
},
|
|
2329
|
+
async markDone(store, job: Job) {
|
|
2330
|
+
jobsStore.updateSelectedStatus("done");
|
|
2331
|
+
store.commit("SET_METRICS", jobsStore.state.jobs);
|
|
2332
|
+
store.commit("RECORD_EVENT", { action: "done", job: job.label, note: "closed cleanly" });
|
|
2333
|
+
},
|
|
2334
|
+
async blockSelected(store, job: Job) {
|
|
2335
|
+
jobsStore.updateSelectedStatus("blocked");
|
|
2336
|
+
store.commit("SET_METRICS", jobsStore.state.jobs);
|
|
2337
|
+
store.commit("RECORD_EVENT", { action: "block", job: job.label, note: "needs review" });
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
return { jobsStore, operations, pulseRevision, setPulseRevision };
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
type WorkbenchStores = ReturnType<typeof createWorkbenchStores>;
|
|
2346
|
+
|
|
2347
|
+
const SURFACE_STYLE = { color: "#f5f5f5", background: "#111827", padding: { left: 1, right: 1 } };
|
|
2348
|
+
const DETAIL_STYLE = { color: "#f8fafc", background: "#172554", padding: { left: 1, right: 1 } };
|
|
2349
|
+
const FOOTER_STYLE = { color: "#d1d5db", background: "#1f2937" };
|
|
2350
|
+
const CURRENT_STYLE = { color: "#ffffff", background: "#14532d" };
|
|
2351
|
+
const MUTED_STYLE = { color: "#9ca3af" };
|
|
2352
|
+
|
|
2353
|
+
function shouldRunSnapshot() {
|
|
2354
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function selectedJob(stores: WorkbenchStores) {
|
|
2358
|
+
return stores.jobsStore.state.jobs[stores.jobsStore.state.selectedIndex];
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function resetStores(stores: WorkbenchStores) {
|
|
2362
|
+
stores.jobsStore.reset();
|
|
2363
|
+
stores.operations.commit("RESET");
|
|
2364
|
+
stores.setPulseRevision(1);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function bumpRevision(stores: WorkbenchStores) {
|
|
2368
|
+
stores.setPulseRevision((value) => value + 1);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
export function App({ stores }: { stores: WorkbenchStores }) {
|
|
2372
|
+
const selected = selectedJob(stores);
|
|
2373
|
+
const manualVNode = v(Text, { style: MUTED_STYLE }, `Tracked jobs: ${stores.jobsStore.state.jobs.length}`);
|
|
2374
|
+
|
|
2375
|
+
return (
|
|
2376
|
+
<Screen title="Operations Workbench">
|
|
2377
|
+
<Text style={{ color: "#ffffff", background: "#0f172a" }}>Valyrian.js modules in terminal apps: state workbench</Text>
|
|
2378
|
+
<Split direction="row" gap={1} sizes={["2fr", "1fr"]}>
|
|
2379
|
+
<Pane style={SURFACE_STYLE}>
|
|
2380
|
+
<Text>Operations Workbench</Text>
|
|
2381
|
+
<Text>{`Pulse revision: ${stores.pulseRevision()} Done jobs: ${stores.operations.state.done} Blocked jobs: ${stores.operations.state.blocked}`}</Text>
|
|
2382
|
+
<Text>{`Dispatches: ${stores.operations.state.dispatched} Last action: ${stores.operations.state.lastAction}`}</Text>
|
|
2383
|
+
{stores.jobsStore.state.jobs.map((job, index) => (
|
|
2384
|
+
<Text state={index === stores.jobsStore.state.selectedIndex ? "current" : undefined} style={index === stores.jobsStore.state.selectedIndex ? undefined : MUTED_STYLE} styles={{ current: CURRENT_STYLE }}>{`${index === stores.jobsStore.state.selectedIndex ? ">" : " "} ${job.label} — ${job.owner} — ${job.status}`}</Text>
|
|
2385
|
+
))}
|
|
2386
|
+
</Pane>
|
|
2387
|
+
<Pane style={DETAIL_STYLE}>
|
|
2388
|
+
<Text>{`Selected: ${selected.label}`}</Text>
|
|
2389
|
+
<Text>{`Owner: ${selected.owner}`}</Text>
|
|
2390
|
+
<Text>{`Status: ${selected.status}`}</Text>
|
|
2391
|
+
{manualVNode}
|
|
2392
|
+
<Text>FluxStore dispatch coordinates operation actions.</Text>
|
|
2393
|
+
<Text>{`Timeline: ${stores.operations.state.timeline.slice(-3).join(" | ")}`}</Text>
|
|
2394
|
+
</Pane>
|
|
2395
|
+
</Split>
|
|
2396
|
+
<Text style={FOOTER_STYLE}>J/K: select Enter: dispatch D: done B: block R: reset Ctrl+C: quit</Text>
|
|
2397
|
+
</Screen>
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
export function createModuleStateWorkbenchDemo(options: TerminalMountOptions = {}): ModuleStateWorkbenchDemo {
|
|
2402
|
+
const stores = createWorkbenchStores();
|
|
2403
|
+
resetStores(stores);
|
|
2404
|
+
let running = true;
|
|
2405
|
+
let session: TerminalSession;
|
|
2406
|
+
|
|
2407
|
+
function quit() {
|
|
2408
|
+
running = false;
|
|
2409
|
+
session.destroy();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function updateAfterOperation() {
|
|
2413
|
+
bumpRevision(stores);
|
|
2414
|
+
session.update();
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
function runSelected(action: "dispatchSelected" | "markDone" | "blockSelected") {
|
|
2418
|
+
const job = selectedJob(stores);
|
|
2419
|
+
void stores.operations.dispatch(action, job).then(updateAfterOperation);
|
|
2420
|
+
updateAfterOperation();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
session = mountTerminal(<App stores={stores} />, {
|
|
2424
|
+
...options,
|
|
2425
|
+
cols: options.cols ?? 100,
|
|
2426
|
+
rows: options.rows ?? 24,
|
|
2427
|
+
keymap: {
|
|
2428
|
+
...options.keymap,
|
|
2429
|
+
bindings: [
|
|
2430
|
+
...(options.keymap?.bindings || []),
|
|
2431
|
+
{ key: "j", command: { id: "jobs.next" }, scope: "global" },
|
|
2432
|
+
{ key: "J", command: { id: "jobs.next" }, scope: "global" },
|
|
2433
|
+
{ key: "k", command: { id: "jobs.previous" }, scope: "global" },
|
|
2434
|
+
{ key: "K", command: { id: "jobs.previous" }, scope: "global" },
|
|
2435
|
+
{ key: "ENTER", command: { id: "jobs.dispatch" }, scope: "global" },
|
|
2436
|
+
{ key: "d", command: { id: "jobs.done" }, scope: "global" },
|
|
2437
|
+
{ key: "D", command: { id: "jobs.done" }, scope: "global" },
|
|
2438
|
+
{ key: "b", command: { id: "jobs.block" }, scope: "global" },
|
|
2439
|
+
{ key: "B", command: { id: "jobs.block" }, scope: "global" },
|
|
2440
|
+
{ key: "r", command: { id: "jobs.reset" }, scope: "global" },
|
|
2441
|
+
{ key: "R", command: { id: "jobs.reset" }, scope: "global" },
|
|
2442
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
2443
|
+
],
|
|
2444
|
+
onCommand(command, context) {
|
|
2445
|
+
if (command.id === "jobs.next") {
|
|
2446
|
+
stores.jobsStore.selectNext();
|
|
2447
|
+
session.update();
|
|
2448
|
+
return true;
|
|
2449
|
+
}
|
|
2450
|
+
if (command.id === "jobs.previous") {
|
|
2451
|
+
stores.jobsStore.selectPrevious();
|
|
2452
|
+
session.update();
|
|
2453
|
+
return true;
|
|
2454
|
+
}
|
|
2455
|
+
if (command.id === "jobs.dispatch") {
|
|
2456
|
+
runSelected("dispatchSelected");
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2459
|
+
if (command.id === "jobs.done") {
|
|
2460
|
+
runSelected("markDone");
|
|
2461
|
+
return true;
|
|
2462
|
+
}
|
|
2463
|
+
if (command.id === "jobs.block") {
|
|
2464
|
+
runSelected("blockSelected");
|
|
2465
|
+
return true;
|
|
2466
|
+
}
|
|
2467
|
+
if (command.id === "jobs.reset") {
|
|
2468
|
+
resetStores(stores);
|
|
2469
|
+
session.update();
|
|
2470
|
+
return true;
|
|
2471
|
+
}
|
|
2472
|
+
if (command.id === "quit") {
|
|
2473
|
+
quit();
|
|
2474
|
+
return true;
|
|
2475
|
+
}
|
|
2476
|
+
return options.keymap?.onCommand?.(command, context);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
return {
|
|
2482
|
+
session,
|
|
2483
|
+
dispatchKey(key: string) {
|
|
2484
|
+
return session.dispatchKey(key);
|
|
2485
|
+
},
|
|
2486
|
+
output() {
|
|
2487
|
+
return session.output();
|
|
2488
|
+
},
|
|
2489
|
+
ansiOutput() {
|
|
2490
|
+
return session.ansiOutput();
|
|
2491
|
+
},
|
|
2492
|
+
isRunning() {
|
|
2493
|
+
return running;
|
|
2494
|
+
},
|
|
2495
|
+
destroy() {
|
|
2496
|
+
running = false;
|
|
2497
|
+
session.destroy();
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
if (import.meta.main) {
|
|
2503
|
+
if (shouldRunSnapshot()) {
|
|
2504
|
+
const demo = createModuleStateWorkbenchDemo({ runtime: "headless", cols: 100, rows: 24 });
|
|
2505
|
+
demo.dispatchKey("J");
|
|
2506
|
+
demo.dispatchKey("ENTER");
|
|
2507
|
+
demo.dispatchKey("D");
|
|
2508
|
+
demo.dispatchKey("K");
|
|
2509
|
+
demo.dispatchKey("B");
|
|
2510
|
+
process.stdout.write(demo.output());
|
|
2511
|
+
process.stdout.write("\n");
|
|
2512
|
+
demo.destroy();
|
|
2513
|
+
} else {
|
|
2514
|
+
createModuleStateWorkbenchDemo();
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
```
|
|
2518
|
+
|
|
2519
|
+
### API Operations Dashboard
|
|
2520
|
+
|
|
2521
|
+
Principal module: `valyrian.js/request`.
|
|
2522
|
+
|
|
2523
|
+
CLIs and TUIs benefit from a clear API client layer. `valyrian.js/request` gives terminal apps one place to define helper methods, response parsing, scoped clients, plugin hooks, and endpoint access. Use a fake/local request in runnable examples, then use real validated endpoints in apps. Let the terminal tree render prepared results while network control flow stays in the app layer.
|
|
2524
|
+
|
|
2525
|
+
Pair `valyrian.js/request` with `valyrian.js/query` when a screen has cached read state. Pair it with `valyrian.js/tasks` when the user starts work that may run longer than a single keypress. The app should expose states users can understand: idle, loading, success, error, cancel, retry. When components read Valyrian reactive state, the terminal frame updates when that state changes. Use `session.update()` for non-reactive state or for an explicit manual refresh.
|
|
2526
|
+
|
|
2527
|
+
Complete demo: **API Operations Dashboard** ([`examples/docs/module-api-dashboard.tsx`](../examples/docs/module-api-dashboard.tsx)). Run it with `bun examples/docs/module-api-dashboard.tsx`, refresh with `R`, force a controlled error with `E`, invalidate cached data with `I`, start or cancel background work with `T` or `C`, and quit with `Ctrl+C`.
|
|
2528
|
+
|
|
2529
|
+
Complete runnable example:
|
|
2530
|
+
|
|
2531
|
+
```tsx
|
|
2532
|
+
import { QueryClient } from "valyrian.js/query";
|
|
2533
|
+
import { request } from "valyrian.js/request";
|
|
2534
|
+
import { Task } from "valyrian.js/tasks";
|
|
2535
|
+
import { Pane, Screen, Split, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
2536
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
2537
|
+
|
|
2538
|
+
interface ServiceStatus {
|
|
2539
|
+
service: string;
|
|
2540
|
+
queue: number;
|
|
2541
|
+
version: string;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
interface ApiDashboardState {
|
|
2545
|
+
errorMode: boolean;
|
|
2546
|
+
service: string;
|
|
2547
|
+
queryStatus: string;
|
|
2548
|
+
apiMessage: string;
|
|
2549
|
+
invalidated: boolean;
|
|
2550
|
+
taskStatus: string;
|
|
2551
|
+
taskResult: string;
|
|
2552
|
+
taskError: string;
|
|
2553
|
+
taskJob: string;
|
|
2554
|
+
running: boolean;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
interface ModuleApiDashboardDemo {
|
|
2558
|
+
session: TerminalSession;
|
|
2559
|
+
dispatchKey(key: string): string;
|
|
2560
|
+
output(): string;
|
|
2561
|
+
ansiOutput(): string;
|
|
2562
|
+
isRunning(): boolean;
|
|
2563
|
+
destroy(): void;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const PANEL_STYLE = { color: "#f8fafc", background: "#0f172a", padding: { left: 1, right: 1 } };
|
|
2567
|
+
const API_STYLE = { color: "#ffffff", background: "#164e63", padding: { left: 1, right: 1 } };
|
|
2568
|
+
const TASK_STYLE = { color: "#ffffff", background: "#312e81", padding: { left: 1, right: 1 } };
|
|
2569
|
+
const FOOTER_STYLE = { color: "#dbeafe", background: "#1e293b" };
|
|
2570
|
+
const ERROR_STYLE = { color: "#ffffff", background: "#7f1d1d" };
|
|
2571
|
+
const SUCCESS_STYLE = { color: "#ffffff", background: "#14532d" };
|
|
2572
|
+
|
|
2573
|
+
function shouldRunSnapshot() {
|
|
2574
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function createState(): ApiDashboardState {
|
|
2578
|
+
return {
|
|
2579
|
+
errorMode: false,
|
|
2580
|
+
service: "idle",
|
|
2581
|
+
queryStatus: "idle",
|
|
2582
|
+
apiMessage: "No request yet",
|
|
2583
|
+
invalidated: false,
|
|
2584
|
+
taskStatus: "idle",
|
|
2585
|
+
taskResult: "Result: none",
|
|
2586
|
+
taskError: "Error: none",
|
|
2587
|
+
taskJob: "none",
|
|
2588
|
+
running: true
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
async function withFakeFetch<T>(state: ApiDashboardState, run: () => Promise<T>) {
|
|
2593
|
+
const originalFetch = globalThis.fetch;
|
|
2594
|
+
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
2595
|
+
const url = String(input);
|
|
2596
|
+
if (state.errorMode || url.endsWith("/failure")) {
|
|
2597
|
+
return Promise.resolve(new Response(JSON.stringify({ message: "controlled request failure" }), { status: 503 }));
|
|
2598
|
+
}
|
|
2599
|
+
return Promise.resolve(new Response(JSON.stringify({ service: "healthy", queue: 3, version: "local-1" }), { status: 200 }));
|
|
2600
|
+
}) as typeof fetch;
|
|
2601
|
+
|
|
2602
|
+
try {
|
|
2603
|
+
return await run();
|
|
2604
|
+
} finally {
|
|
2605
|
+
globalThis.fetch = originalFetch;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function waitForTask(signal: AbortSignal) {
|
|
2610
|
+
return new Promise<void>((resolve) => {
|
|
2611
|
+
const timer = setTimeout(resolve, 20);
|
|
2612
|
+
signal.addEventListener(
|
|
2613
|
+
"abort",
|
|
2614
|
+
() => {
|
|
2615
|
+
clearTimeout(timer);
|
|
2616
|
+
resolve();
|
|
2617
|
+
},
|
|
2618
|
+
{ once: true }
|
|
2619
|
+
);
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
function syncTaskState(state: ApiDashboardState, task: Task<string, string>) {
|
|
2624
|
+
const snapshot = task.state;
|
|
2625
|
+
state.taskStatus = snapshot.status;
|
|
2626
|
+
state.taskResult = snapshot.result ? `Result: ${snapshot.result}` : "Result: none";
|
|
2627
|
+
state.taskError = snapshot.error instanceof Error ? `Error: ${snapshot.error.message}` : "Error: none";
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
export function App({ state }: { state: ApiDashboardState }) {
|
|
2631
|
+
return (
|
|
2632
|
+
<Screen title="API Operations Dashboard">
|
|
2633
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Valyrian.js modules in terminal apps: API dashboard</Text>
|
|
2634
|
+
<Split direction="row" gap={1} sizes={["1fr", "1fr"]}>
|
|
2635
|
+
<Pane style={API_STYLE}>
|
|
2636
|
+
<Text>API Operations Dashboard</Text>
|
|
2637
|
+
<Text>{`Mode: ${state.errorMode ? "forced error" : "success"}`}</Text>
|
|
2638
|
+
<Text state={state.queryStatus === "error" ? "error" : "success"} styles={{ error: ERROR_STYLE, success: SUCCESS_STYLE }}>{state.apiMessage}</Text>
|
|
2639
|
+
<Text>{`Query: ${state.queryStatus}`}</Text>
|
|
2640
|
+
<Text>{`Invalidated: ${state.invalidated ? "yes" : "no"}`}</Text>
|
|
2641
|
+
<Text>{`Service: ${state.service}`}</Text>
|
|
2642
|
+
</Pane>
|
|
2643
|
+
<Pane style={TASK_STYLE}>
|
|
2644
|
+
<Text>Maintenance task</Text>
|
|
2645
|
+
<Text>{`Task: ${state.taskStatus}`}</Text>
|
|
2646
|
+
<Text>{`Job: ${state.taskJob}`}</Text>
|
|
2647
|
+
<Text>{state.taskResult}</Text>
|
|
2648
|
+
<Text>{state.taskError}</Text>
|
|
2649
|
+
<Text>Request and query use a local fake fetch, so this example never calls the network.</Text>
|
|
2650
|
+
</Pane>
|
|
2651
|
+
</Split>
|
|
2652
|
+
<Pane style={PANEL_STYLE}>
|
|
2653
|
+
<Text>R: refresh E: force error I: invalidate T: start task C: cancel Ctrl+C: quit</Text>
|
|
2654
|
+
<Text style={FOOTER_STYLE}>See the Valyrian.js documentation for request, query, and tasks.</Text>
|
|
2655
|
+
</Pane>
|
|
2656
|
+
</Screen>
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
export function createModuleApiDashboardDemo(options: TerminalMountOptions = {}): ModuleApiDashboardDemo {
|
|
2661
|
+
const state = createState();
|
|
2662
|
+
const client = new QueryClient({ staleTime: 0, cacheTime: 1000 });
|
|
2663
|
+
const api = request.new("http://terminal.local", { allowedMethods: ["get"] });
|
|
2664
|
+
const serviceQuery = client.query({
|
|
2665
|
+
key: ["terminal", "service-status"],
|
|
2666
|
+
fetcher: () => api.get("/health")
|
|
2667
|
+
});
|
|
2668
|
+
const maintenanceTask = new Task<string, string>(async (label, { signal }) => {
|
|
2669
|
+
if (label === "scan") {
|
|
2670
|
+
await waitForTask(signal);
|
|
2671
|
+
if (signal.aborted) return "Cancelled stale scan";
|
|
2672
|
+
}
|
|
2673
|
+
if (label === "fail") throw new Error("controlled task failure");
|
|
2674
|
+
return `Completed ${label}`;
|
|
2675
|
+
}, { strategy: "restartable" });
|
|
2676
|
+
let session: TerminalSession;
|
|
2677
|
+
const offTaskState = maintenanceTask.on("state", () => {
|
|
2678
|
+
syncTaskState(state, maintenanceTask);
|
|
2679
|
+
session?.update();
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
function quit() {
|
|
2683
|
+
state.running = false;
|
|
2684
|
+
offTaskState();
|
|
2685
|
+
client.clear();
|
|
2686
|
+
maintenanceTask.reset();
|
|
2687
|
+
session.destroy();
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
async function refresh() {
|
|
2691
|
+
if (state.queryStatus === "loading") {
|
|
2692
|
+
state.apiMessage = "Refresh already in progress";
|
|
2693
|
+
session.update();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
state.queryStatus = "loading";
|
|
2698
|
+
state.apiMessage = state.errorMode ? "Loading forced error" : "Loading service status";
|
|
2699
|
+
session.update();
|
|
2700
|
+
try {
|
|
2701
|
+
const data = (await withFakeFetch(state, () => serviceQuery.fetch())) as ServiceStatus;
|
|
2702
|
+
state.service = data.service;
|
|
2703
|
+
state.queryStatus = serviceQuery.state.status;
|
|
2704
|
+
state.apiMessage = `Service: ${data.service} Queue: ${data.queue} Version: ${data.version}`;
|
|
2705
|
+
state.invalidated = false;
|
|
2706
|
+
} catch {
|
|
2707
|
+
state.service = "unavailable";
|
|
2708
|
+
state.queryStatus = serviceQuery.state.status;
|
|
2709
|
+
state.apiMessage = "API error: controlled request failure";
|
|
2710
|
+
}
|
|
2711
|
+
session.update();
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function invalidate() {
|
|
2715
|
+
serviceQuery.invalidate();
|
|
2716
|
+
state.queryStatus = serviceQuery.state.status;
|
|
2717
|
+
state.invalidated = true;
|
|
2718
|
+
state.apiMessage = "Cache invalidated; refresh will fetch again";
|
|
2719
|
+
session.update();
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
async function runMaintenance(label: string, job: string) {
|
|
2723
|
+
state.taskJob = job;
|
|
2724
|
+
syncTaskState(state, maintenanceTask);
|
|
2725
|
+
session.update();
|
|
2726
|
+
try {
|
|
2727
|
+
await maintenanceTask.run(label);
|
|
2728
|
+
} catch {
|
|
2729
|
+
// Task state stores the controlled error for the UI.
|
|
2730
|
+
}
|
|
2731
|
+
syncTaskState(state, maintenanceTask);
|
|
2732
|
+
session.update();
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function cancelMaintenance() {
|
|
2736
|
+
state.taskJob = "stale scan";
|
|
2737
|
+
void maintenanceTask.run("scan");
|
|
2738
|
+
syncTaskState(state, maintenanceTask);
|
|
2739
|
+
maintenanceTask.cancel();
|
|
2740
|
+
syncTaskState(state, maintenanceTask);
|
|
2741
|
+
session.update();
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
session = mountTerminal(<App state={state} />, {
|
|
2745
|
+
...options,
|
|
2746
|
+
cols: options.cols ?? 96,
|
|
2747
|
+
rows: options.rows ?? 22,
|
|
2748
|
+
keymap: {
|
|
2749
|
+
...options.keymap,
|
|
2750
|
+
bindings: [
|
|
2751
|
+
...(options.keymap?.bindings || []),
|
|
2752
|
+
{ key: "r", command: { id: "api.refresh" }, scope: "global" },
|
|
2753
|
+
{ key: "R", command: { id: "api.refresh" }, scope: "global" },
|
|
2754
|
+
{ key: "e", command: { id: "api.error" }, scope: "global" },
|
|
2755
|
+
{ key: "E", command: { id: "api.error" }, scope: "global" },
|
|
2756
|
+
{ key: "i", command: { id: "api.invalidate" }, scope: "global" },
|
|
2757
|
+
{ key: "I", command: { id: "api.invalidate" }, scope: "global" },
|
|
2758
|
+
{ key: "t", command: { id: "task.run" }, scope: "global" },
|
|
2759
|
+
{ key: "T", command: { id: "task.run" }, scope: "global" },
|
|
2760
|
+
{ key: "c", command: { id: "task.cancel" }, scope: "global" },
|
|
2761
|
+
{ key: "C", command: { id: "task.cancel" }, scope: "global" },
|
|
2762
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
2763
|
+
],
|
|
2764
|
+
onCommand(command, context) {
|
|
2765
|
+
if (command.id === "api.refresh") {
|
|
2766
|
+
void refresh();
|
|
2767
|
+
return true;
|
|
2768
|
+
}
|
|
2769
|
+
if (command.id === "api.error") {
|
|
2770
|
+
state.errorMode = true;
|
|
2771
|
+
state.apiMessage = "Next refresh will use controlled error";
|
|
2772
|
+
session.update();
|
|
2773
|
+
return true;
|
|
2774
|
+
}
|
|
2775
|
+
if (command.id === "api.invalidate") {
|
|
2776
|
+
invalidate();
|
|
2777
|
+
return true;
|
|
2778
|
+
}
|
|
2779
|
+
if (command.id === "task.run") {
|
|
2780
|
+
void runMaintenance("maintenance", "maintenance");
|
|
2781
|
+
return true;
|
|
2782
|
+
}
|
|
2783
|
+
if (command.id === "task.cancel") {
|
|
2784
|
+
cancelMaintenance();
|
|
2785
|
+
return true;
|
|
2786
|
+
}
|
|
2787
|
+
if (command.id === "quit") {
|
|
2788
|
+
quit();
|
|
2789
|
+
return true;
|
|
2790
|
+
}
|
|
2791
|
+
return options.keymap?.onCommand?.(command, context);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
return {
|
|
2797
|
+
session,
|
|
2798
|
+
dispatchKey(key: string) {
|
|
2799
|
+
return session.dispatchKey(key);
|
|
2800
|
+
},
|
|
2801
|
+
output() {
|
|
2802
|
+
return session.output();
|
|
2803
|
+
},
|
|
2804
|
+
ansiOutput() {
|
|
2805
|
+
return session.ansiOutput();
|
|
2806
|
+
},
|
|
2807
|
+
isRunning() {
|
|
2808
|
+
return state.running;
|
|
2809
|
+
},
|
|
2810
|
+
destroy() {
|
|
2811
|
+
state.running = false;
|
|
2812
|
+
offTaskState();
|
|
2813
|
+
client.clear();
|
|
2814
|
+
maintenanceTask.reset();
|
|
2815
|
+
session.destroy();
|
|
2816
|
+
}
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
if (import.meta.main) {
|
|
2821
|
+
if (shouldRunSnapshot()) {
|
|
2822
|
+
const demo = createModuleApiDashboardDemo({ runtime: "headless", cols: 96, rows: 22 });
|
|
2823
|
+
demo.dispatchKey("R");
|
|
2824
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2825
|
+
demo.dispatchKey("I");
|
|
2826
|
+
demo.dispatchKey("E");
|
|
2827
|
+
demo.dispatchKey("R");
|
|
2828
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2829
|
+
demo.dispatchKey("T");
|
|
2830
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2831
|
+
demo.dispatchKey("C");
|
|
2832
|
+
process.stdout.write(demo.output());
|
|
2833
|
+
process.stdout.write("\n");
|
|
2834
|
+
demo.destroy();
|
|
2835
|
+
} else {
|
|
2836
|
+
createModuleApiDashboardDemo();
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
```
|
|
2840
|
+
|
|
2841
|
+
### Billing/Settings Wizard
|
|
2842
|
+
|
|
2843
|
+
Use `valyrian.js/forms` and `valyrian.js/native-store` when terminal input becomes app data instead of throwaway text.
|
|
2844
|
+
|
|
2845
|
+
Terminal input should move through cleanup, validation, visible errors, save, and reset. Do not bury validation in output text after the save already ran. Users need the error at the point where the value can be corrected.
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
Complete demo: **Billing/Settings Wizard** ([`examples/docs/module-form-workflow.tsx`](../examples/docs/module-form-workflow.tsx)). Run it with `bun examples/docs/module-form-workflow.tsx`, fill the name with `N`, switch language with `L`, save with `S` or `Enter`, reset with `R`, clear saved data with `X`, and quit with `Ctrl+C`.
|
|
2849
|
+
|
|
2850
|
+
Complete runnable example:
|
|
2851
|
+
|
|
2852
|
+
```tsx
|
|
2853
|
+
import { FormStore } from "valyrian.js/forms";
|
|
2854
|
+
import { Money, formatMoney, parseMoneyInput } from "valyrian.js/money";
|
|
2855
|
+
import { createNativeStore, StorageType } from "valyrian.js/native-store";
|
|
2856
|
+
import { setLang, setLog, setStoreStrategy, setTranslations, t } from "valyrian.js/translate";
|
|
2857
|
+
import { get, set } from "valyrian.js/utils";
|
|
2858
|
+
import { Input, Pane, Screen, Split, Text, mountTerminal } from "@valyrianjs/terminal";
|
|
2859
|
+
import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
|
|
2860
|
+
|
|
2861
|
+
type Language = "en" | "es";
|
|
2862
|
+
|
|
2863
|
+
type BillingForm = {
|
|
2864
|
+
name: string;
|
|
2865
|
+
seats: number;
|
|
2866
|
+
budgetCents: number;
|
|
2867
|
+
};
|
|
2868
|
+
|
|
2869
|
+
interface SavedBilling {
|
|
2870
|
+
name: string;
|
|
2871
|
+
seats: number;
|
|
2872
|
+
budgetCents: number;
|
|
2873
|
+
workspace: string;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
interface FormWorkflowState {
|
|
2877
|
+
language: Language;
|
|
2878
|
+
savedWorkspace: string;
|
|
2879
|
+
savedBilling: SavedBilling | null;
|
|
2880
|
+
saveMessage: string;
|
|
2881
|
+
resetMessage: string;
|
|
2882
|
+
running: boolean;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
interface ModuleFormWorkflowDemo {
|
|
2886
|
+
session: TerminalSession;
|
|
2887
|
+
dispatchKey(key: string): string;
|
|
2888
|
+
output(): string;
|
|
2889
|
+
ansiOutput(): string;
|
|
2890
|
+
isRunning(): boolean;
|
|
2891
|
+
destroy(): void;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
type FormWorkflowOptions = TerminalMountOptions & {
|
|
2895
|
+
storeKey?: string;
|
|
2896
|
+
};
|
|
2897
|
+
|
|
2898
|
+
const DEFAULT_STORE_KEY = "valyrian-terminal-docs.billing-settings-wizard";
|
|
2899
|
+
const DEFAULT_WORKSPACE = "Northwind CLI";
|
|
2900
|
+
|
|
2901
|
+
const SHELL_STYLE = { color: "#f8fafc", background: "#111827", padding: { left: 1, right: 1 } };
|
|
2902
|
+
const FORM_STYLE = { color: "#ffffff", background: "#0f3b3e", padding: { left: 1, right: 1 } };
|
|
2903
|
+
const SUMMARY_STYLE = { color: "#ffffff", background: "#3b1f4f", padding: { left: 1, right: 1 } };
|
|
2904
|
+
const FOOTER_STYLE = { color: "#e5e7eb", background: "#1f2937" };
|
|
2905
|
+
const MUTED_STYLE = { color: "#cbd5e1" };
|
|
2906
|
+
const ERROR_STYLE = { color: "#fecaca" };
|
|
2907
|
+
|
|
2908
|
+
function shouldRunSnapshot() {
|
|
2909
|
+
return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
function configureTranslations() {
|
|
2914
|
+
setLog(false);
|
|
2915
|
+
let storedLanguage = "en";
|
|
2916
|
+
setStoreStrategy({
|
|
2917
|
+
get: () => storedLanguage,
|
|
2918
|
+
set: (language) => {
|
|
2919
|
+
storedLanguage = language;
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
2922
|
+
setTranslations(
|
|
2923
|
+
{
|
|
2924
|
+
heading: "Billing/Settings Wizard",
|
|
2925
|
+
language: "Language: English",
|
|
2926
|
+
workspace: "Workspace",
|
|
2927
|
+
customer: "Customer",
|
|
2928
|
+
seats: "Seats",
|
|
2929
|
+
budget: "Budget"
|
|
2930
|
+
},
|
|
2931
|
+
{
|
|
2932
|
+
es: {
|
|
2933
|
+
heading: "Asistente de cobro/configuración",
|
|
2934
|
+
language: "Idioma: español",
|
|
2935
|
+
workspace: "Espacio",
|
|
2936
|
+
customer: "Cliente",
|
|
2937
|
+
seats: "Asientos",
|
|
2938
|
+
budget: "Presupuesto"
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
);
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function parseSeats(value: unknown) {
|
|
2945
|
+
const seats = Number(String(value).trim());
|
|
2946
|
+
return Number.isFinite(seats) ? seats : 0;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
function parseBudgetCents(value: unknown) {
|
|
2950
|
+
try {
|
|
2951
|
+
return parseMoneyInput(String(value), { decimalPlaces: 2 }).toCents();
|
|
2952
|
+
} catch {
|
|
2953
|
+
return -1;
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
function createBillingForm(onSaved: (billing: SavedBilling) => void) {
|
|
2958
|
+
return new FormStore<BillingForm>({
|
|
2959
|
+
state: { name: "Ada", seats: 12, budgetCents: 129950 },
|
|
2960
|
+
schema: {
|
|
2961
|
+
type: "object",
|
|
2962
|
+
properties: {
|
|
2963
|
+
name: { type: "string", minLength: 1 },
|
|
2964
|
+
seats: { type: "number", minimum: 1 },
|
|
2965
|
+
budgetCents: { type: "number", minimum: 100 }
|
|
2966
|
+
},
|
|
2967
|
+
required: ["name", "seats", "budgetCents"]
|
|
2968
|
+
},
|
|
2969
|
+
clean: {
|
|
2970
|
+
name: (value) => String(value).trim(),
|
|
2971
|
+
seats: parseSeats,
|
|
2972
|
+
budgetCents: parseBudgetCents
|
|
2973
|
+
},
|
|
2974
|
+
format: {
|
|
2975
|
+
seats: (value) => String(value),
|
|
2976
|
+
budgetCents: (value) => (Number(value) / 100).toFixed(2)
|
|
2977
|
+
},
|
|
2978
|
+
onSubmit(values) {
|
|
2979
|
+
onSaved({
|
|
2980
|
+
name: values.name,
|
|
2981
|
+
seats: values.seats,
|
|
2982
|
+
budgetCents: values.budgetCents,
|
|
2983
|
+
workspace: DEFAULT_WORKSPACE
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function formatBudget(cents: number, language: Language) {
|
|
2990
|
+
const money = Money.fromCents(Number.isFinite(cents) ? cents : 0);
|
|
2991
|
+
return formatMoney(money, { currency: "USD", locale: language === "es" ? "es-MX" : "en-US" });
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function fieldError(form: FormStore<BillingForm>, field: keyof BillingForm) {
|
|
2995
|
+
return String(form.validationErrors[field] || "not checked");
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
function savedSummary(saved: SavedBilling | null, language: Language) {
|
|
2999
|
+
if (!saved) return "none";
|
|
3000
|
+
return `${saved.name} / ${saved.seats} seats / ${formatBudget(saved.budgetCents, language)}`;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
export function App({ form, state, onSubmit }: { form: FormStore<BillingForm>; state: FormWorkflowState; onSubmit: () => void }) {
|
|
3004
|
+
setLang(state.language);
|
|
3005
|
+
const data = { billing: { name: form.state.name, seats: form.state.seats } };
|
|
3006
|
+
set(data, "billing.workspace", DEFAULT_WORKSPACE);
|
|
3007
|
+
set(data, "billing.total", formatBudget(form.state.budgetCents, state.language));
|
|
3008
|
+
const workspace = String(get(data, "billing.workspace", DEFAULT_WORKSPACE));
|
|
3009
|
+
const displayBudget = String(get(data, "billing.total", formatBudget(0, state.language)));
|
|
3010
|
+
|
|
3011
|
+
return (
|
|
3012
|
+
<Screen title="Billing/Settings Wizard">
|
|
3013
|
+
<Text style={{ color: "#ffffff", background: "#020617" }}>Valyrian.js modules in terminal apps: form workflow</Text>
|
|
3014
|
+
<Split direction="row" gap={1} sizes={["1fr", "1fr"]}>
|
|
3015
|
+
<Pane style={FORM_STYLE}>
|
|
3016
|
+
<Text>{t("heading")}</Text>
|
|
3017
|
+
<Text>{t("language")}</Text>
|
|
3018
|
+
<Text>{`${t("workspace")}: ${workspace}`}</Text>
|
|
3019
|
+
<Text>{t("customer")}</Text>
|
|
3020
|
+
<Input id="billing-name" value={String(form.state.name)} placeholder="Customer name" onchange={(event) => form.setField("name", event.value)} onsubmit={onSubmit} />
|
|
3021
|
+
<Text>{`Name: ${form.state.name || "missing"}`}</Text>
|
|
3022
|
+
<Text style={ERROR_STYLE}>{`Name error: ${fieldError(form, "name")}`}</Text>
|
|
3023
|
+
<Text>{t("seats")}</Text>
|
|
3024
|
+
<Input id="billing-seats" value={String(form.formatValue("seats", form.state.seats))} placeholder="Seat count" onchange={(event) => form.setField("seats", event.value)} onsubmit={onSubmit} />
|
|
3025
|
+
<Text>{`Seats: ${form.state.seats}`}</Text>
|
|
3026
|
+
<Text style={ERROR_STYLE}>{`Seats error: ${fieldError(form, "seats")}`}</Text>
|
|
3027
|
+
<Text>{t("budget")}</Text>
|
|
3028
|
+
<Input id="billing-budget" value={String(form.formatValue("budgetCents", form.state.budgetCents))} placeholder="Monthly budget" onchange={(event) => form.setField("budgetCents", event.value)} onsubmit={onSubmit} />
|
|
3029
|
+
<Text>{`Budget: ${displayBudget}`}</Text>
|
|
3030
|
+
<Text style={ERROR_STYLE}>{`Budget error: ${fieldError(form, "budgetCents")}`}</Text>
|
|
3031
|
+
</Pane>
|
|
3032
|
+
<Pane style={SUMMARY_STYLE}>
|
|
3033
|
+
<Text>Cleaned billing summary</Text>
|
|
3034
|
+
<Text>{`Workspace: ${workspace}`}</Text>
|
|
3035
|
+
<Text>{`Saved workspace: ${state.savedWorkspace || "none"}`}</Text>
|
|
3036
|
+
<Text>{`Saved billing: ${savedSummary(state.savedBilling, state.language)}`}</Text>
|
|
3037
|
+
<Text>{`Form dirty: ${form.isDirty}`}</Text>
|
|
3038
|
+
<Text>{`Form success: ${form.success}`}</Text>
|
|
3039
|
+
<Text>{`Save: ${state.saveMessage}`}</Text>
|
|
3040
|
+
<Text>{`Reset: ${state.resetMessage}`}</Text>
|
|
3041
|
+
<Text style={MUTED_STYLE}>Forms clean input, native-store persists workspace, translate changes labels, money formats totals, and utils shape summary data.</Text>
|
|
3042
|
+
</Pane>
|
|
3043
|
+
</Split>
|
|
3044
|
+
<Pane style={SHELL_STYLE}>
|
|
3045
|
+
<Text>N: fill name Tab: next field Shift+Tab: previous field L: language S/Enter: save R: reset form X: clear saved Ctrl+C: quit</Text>
|
|
3046
|
+
<Text style={FOOTER_STYLE}>See the Valyrian.js documentation for forms, native-store, translate, money, and utils.</Text>
|
|
3047
|
+
</Pane>
|
|
3048
|
+
</Screen>
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
export function createModuleFormWorkflowDemo(options: FormWorkflowOptions = {}): ModuleFormWorkflowDemo {
|
|
3053
|
+
configureTranslations();
|
|
3054
|
+
const settings = createNativeStore<{ workspace: string }>(options.storeKey ?? DEFAULT_STORE_KEY, {}, StorageType.Session, true);
|
|
3055
|
+
const state: FormWorkflowState = {
|
|
3056
|
+
language: "en",
|
|
3057
|
+
savedWorkspace: String(settings.get("workspace") || ""),
|
|
3058
|
+
savedBilling: null,
|
|
3059
|
+
saveMessage: "not saved",
|
|
3060
|
+
resetMessage: "not reset",
|
|
3061
|
+
running: true
|
|
3062
|
+
};
|
|
3063
|
+
const form = createBillingForm((billing) => {
|
|
3064
|
+
state.savedBilling = billing;
|
|
3065
|
+
state.savedWorkspace = billing.workspace;
|
|
3066
|
+
settings.set("workspace", billing.workspace);
|
|
3067
|
+
state.saveMessage = `saved ${billing.name} for ${billing.seats} seats`;
|
|
3068
|
+
});
|
|
3069
|
+
let session: TerminalSession;
|
|
3070
|
+
|
|
3071
|
+
function quit() {
|
|
3072
|
+
state.running = false;
|
|
3073
|
+
settings.cleanup();
|
|
3074
|
+
session.destroy();
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
function resetForm() {
|
|
3078
|
+
form.reset();
|
|
3079
|
+
state.savedBilling = null;
|
|
3080
|
+
state.savedWorkspace = "";
|
|
3081
|
+
state.saveMessage = "not saved";
|
|
3082
|
+
state.resetMessage = "form reset";
|
|
3083
|
+
settings.clear();
|
|
3084
|
+
session.update();
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
async function submitForm() {
|
|
3088
|
+
const ok = await form.submit();
|
|
3089
|
+
if (!ok) {
|
|
3090
|
+
state.saveMessage = "blocked by validation";
|
|
3091
|
+
}
|
|
3092
|
+
session.update();
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
session = mountTerminal(<App form={form} state={state} onSubmit={submitForm} />, {
|
|
3096
|
+
...options,
|
|
3097
|
+
cols: options.cols ?? 100,
|
|
3098
|
+
rows: options.rows ?? 24,
|
|
3099
|
+
keymap: {
|
|
3100
|
+
...options.keymap,
|
|
3101
|
+
bindings: [
|
|
3102
|
+
...(options.keymap?.bindings || []),
|
|
3103
|
+
{ key: "n", command: { id: "form.name" }, scope: "global" },
|
|
3104
|
+
{ key: "N", command: { id: "form.name" }, scope: "global" },
|
|
3105
|
+
{ key: "l", command: { id: "form.language" }, scope: "global" },
|
|
3106
|
+
{ key: "L", command: { id: "form.language" }, scope: "global" },
|
|
3107
|
+
{ key: "s", command: { id: "form.save" }, scope: "global" },
|
|
3108
|
+
{ key: "S", command: { id: "form.save" }, scope: "global" },
|
|
3109
|
+
{ key: "r", command: { id: "form.reset" }, scope: "global" },
|
|
3110
|
+
{ key: "R", command: { id: "form.reset" }, scope: "global" },
|
|
3111
|
+
{ key: "x", command: { id: "form.clear" }, scope: "global" },
|
|
3112
|
+
{ key: "X", command: { id: "form.clear" }, scope: "global" },
|
|
3113
|
+
{ key: "CTRL_C", command: { id: "quit" }, scope: "global" }
|
|
3114
|
+
],
|
|
3115
|
+
onCommand(command, context) {
|
|
3116
|
+
if (command.id === "form.name") {
|
|
3117
|
+
form.setField("name", "Ada Lovelace");
|
|
3118
|
+
session.update();
|
|
3119
|
+
return true;
|
|
3120
|
+
}
|
|
3121
|
+
if (command.id === "form.language") {
|
|
3122
|
+
state.language = state.language === "en" ? "es" : "en";
|
|
3123
|
+
setLang(state.language);
|
|
3124
|
+
session.update();
|
|
3125
|
+
return true;
|
|
3126
|
+
}
|
|
3127
|
+
if (command.id === "form.save") {
|
|
3128
|
+
void submitForm();
|
|
3129
|
+
return true;
|
|
3130
|
+
}
|
|
3131
|
+
if (command.id === "form.reset") {
|
|
3132
|
+
resetForm();
|
|
3133
|
+
return true;
|
|
3134
|
+
}
|
|
3135
|
+
if (command.id === "form.clear") {
|
|
3136
|
+
state.savedWorkspace = "";
|
|
3137
|
+
state.savedBilling = null;
|
|
3138
|
+
state.saveMessage = "not saved";
|
|
3139
|
+
settings.clear();
|
|
3140
|
+
session.update();
|
|
3141
|
+
return true;
|
|
3142
|
+
}
|
|
3143
|
+
if (command.id === "quit") {
|
|
3144
|
+
quit();
|
|
3145
|
+
return true;
|
|
3146
|
+
}
|
|
3147
|
+
return options.keymap?.onCommand?.(command, context);
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
|
|
3152
|
+
session.focus("billing-name");
|
|
3153
|
+
|
|
3154
|
+
return {
|
|
3155
|
+
session,
|
|
3156
|
+
dispatchKey(key: string) {
|
|
3157
|
+
return session.dispatchKey(key);
|
|
3158
|
+
},
|
|
3159
|
+
output() {
|
|
3160
|
+
return session.output();
|
|
3161
|
+
},
|
|
3162
|
+
ansiOutput() {
|
|
3163
|
+
return session.ansiOutput();
|
|
3164
|
+
},
|
|
3165
|
+
isRunning() {
|
|
3166
|
+
return state.running;
|
|
3167
|
+
},
|
|
3168
|
+
destroy() {
|
|
3169
|
+
state.running = false;
|
|
3170
|
+
settings.cleanup();
|
|
3171
|
+
session.destroy();
|
|
3172
|
+
}
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
if (import.meta.main) {
|
|
3177
|
+
if (shouldRunSnapshot()) {
|
|
3178
|
+
const demo = createModuleFormWorkflowDemo({ runtime: "headless", cols: 100, rows: 24 });
|
|
3179
|
+
demo.dispatchKey("S");
|
|
3180
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3181
|
+
demo.dispatchKey("N");
|
|
3182
|
+
demo.dispatchKey("L");
|
|
3183
|
+
demo.dispatchKey("S");
|
|
3184
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3185
|
+
process.stdout.write(demo.output());
|
|
3186
|
+
process.stdout.write("\n");
|
|
3187
|
+
demo.destroy();
|
|
3188
|
+
} else {
|
|
3189
|
+
createModuleFormWorkflowDemo();
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
```
|
|
3193
|
+
|
|
3194
|
+
## Where to learn the full APIs
|
|
3195
|
+
|
|
3196
|
+
This guide explains how Valyrian.js modules fit terminal-specific app patterns. For complete signatures, options, edge cases, and deeper examples, refer to the Valyrian.js docs for each module.
|