@voidlabs.gg/voidshift 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +136 -2
  3. package/dist/checks.d.ts +9 -0
  4. package/dist/checks.js +51 -0
  5. package/dist/commands/args.d.ts +13 -0
  6. package/dist/commands/args.js +22 -0
  7. package/dist/commands/config.d.ts +1 -0
  8. package/dist/commands/config.js +181 -0
  9. package/dist/commands/doctor.d.ts +3 -0
  10. package/dist/commands/doctor.js +52 -0
  11. package/dist/commands/help.d.ts +3 -0
  12. package/dist/commands/help.js +59 -0
  13. package/dist/commands/setup.d.ts +2 -0
  14. package/dist/commands/setup.js +135 -0
  15. package/dist/commands/status.d.ts +2 -0
  16. package/dist/commands/status.js +60 -0
  17. package/dist/commands/tools.d.ts +9 -0
  18. package/dist/commands/tools.js +111 -0
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/config.d.ts +9 -0
  22. package/dist/config.js +29 -0
  23. package/dist/dotted.d.ts +9 -0
  24. package/dist/dotted.js +86 -0
  25. package/dist/loader.d.ts +6 -0
  26. package/dist/loader.js +51 -0
  27. package/dist/main.d.ts +2 -0
  28. package/dist/main.js +93 -0
  29. package/dist/registry.d.ts +8 -0
  30. package/dist/registry.js +26 -0
  31. package/dist/tool.d.ts +51 -0
  32. package/dist/tool.js +4 -0
  33. package/dist/tui/actions.d.ts +17 -0
  34. package/dist/tui/actions.js +99 -0
  35. package/dist/tui/app.d.ts +6 -0
  36. package/dist/tui/app.js +246 -0
  37. package/dist/tui/context.d.ts +62 -0
  38. package/dist/tui/context.js +48 -0
  39. package/dist/tui/data.d.ts +196 -0
  40. package/dist/tui/data.js +302 -0
  41. package/dist/tui/format.d.ts +11 -0
  42. package/dist/tui/format.js +54 -0
  43. package/dist/tui/home.d.ts +51 -0
  44. package/dist/tui/home.js +336 -0
  45. package/dist/tui/keys.d.ts +2 -0
  46. package/dist/tui/keys.js +107 -0
  47. package/dist/tui/nav.d.ts +24 -0
  48. package/dist/tui/nav.js +32 -0
  49. package/dist/tui/palette.d.ts +32 -0
  50. package/dist/tui/palette.js +269 -0
  51. package/dist/tui/run.d.ts +1 -0
  52. package/dist/tui/run.js +20 -0
  53. package/dist/tui/screens/agents.d.ts +6 -0
  54. package/dist/tui/screens/agents.js +44 -0
  55. package/dist/tui/screens/awake.d.ts +23 -0
  56. package/dist/tui/screens/awake.js +209 -0
  57. package/dist/tui/screens/checks.d.ts +11 -0
  58. package/dist/tui/screens/checks.js +106 -0
  59. package/dist/tui/screens/homes.d.ts +14 -0
  60. package/dist/tui/screens/homes.js +99 -0
  61. package/dist/tui/screens/list-window.d.ts +6 -0
  62. package/dist/tui/screens/list-window.js +18 -0
  63. package/dist/tui/screens/log.d.ts +26 -0
  64. package/dist/tui/screens/log.js +266 -0
  65. package/dist/tui/screens/memory.d.ts +24 -0
  66. package/dist/tui/screens/memory.js +174 -0
  67. package/dist/tui/screens/project-form.d.ts +14 -0
  68. package/dist/tui/screens/project-form.js +137 -0
  69. package/dist/tui/screens/projects.d.ts +17 -0
  70. package/dist/tui/screens/projects.js +109 -0
  71. package/dist/tui/screens/runs.d.ts +43 -0
  72. package/dist/tui/screens/runs.js +420 -0
  73. package/dist/tui/screens/settings.d.ts +17 -0
  74. package/dist/tui/screens/settings.js +153 -0
  75. package/dist/tui/screens/workflow-form.d.ts +58 -0
  76. package/dist/tui/screens/workflow-form.js +375 -0
  77. package/dist/tui/screens/workflows.d.ts +41 -0
  78. package/dist/tui/screens/workflows.js +174 -0
  79. package/dist/tui/screens.d.ts +30 -0
  80. package/dist/tui/screens.js +172 -0
  81. package/dist/tui/theme.d.ts +14 -0
  82. package/dist/tui/theme.js +18 -0
  83. package/dist/tui/widgets.d.ts +122 -0
  84. package/dist/tui/widgets.js +264 -0
  85. package/dist/ui.d.ts +5 -0
  86. package/dist/ui.js +82 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.js +17 -0
  89. package/package.json +59 -4
@@ -0,0 +1,269 @@
1
+ // The command palette. ":" opens a filterable list of every vsf command line,
2
+ // built from the umbrella usage table and each added tool's help output, plus
3
+ // picks that fill in real card names ("agents retry ENG-1407"). Enter runs the
4
+ // command through the action runner; commands that need a terminal are marked
5
+ // and handed the real terminal instead.
6
+ import React, { useEffect, useMemo, useState } from 'react';
7
+ import { Box, Text, useInput } from 'ink';
8
+ import { needsTerminal } from "./actions.js";
9
+ import { editText, Panel } from "./widgets.js";
10
+ import { theme } from "./theme.js";
11
+ import { useData } from "./data.js";
12
+ const h = React.createElement;
13
+ // Splits "agents retry 'ENG 1'" into words. Quotes group words.
14
+ export function splitCommand(text) {
15
+ const out = [];
16
+ let cur = '';
17
+ let quote = null;
18
+ let any = false;
19
+ for (const ch of text) {
20
+ if (quote) {
21
+ if (ch === quote)
22
+ quote = null;
23
+ else
24
+ cur += ch;
25
+ }
26
+ else if (ch === '"' || ch === "'") {
27
+ quote = ch;
28
+ any = true;
29
+ }
30
+ else if (/\s/.test(ch)) {
31
+ if (cur || any)
32
+ out.push(cur);
33
+ cur = '';
34
+ any = false;
35
+ }
36
+ else
37
+ cur += ch;
38
+ }
39
+ if (cur || any)
40
+ out.push(cur);
41
+ return out;
42
+ }
43
+ const isPlaceholder = (word) => word.startsWith('[') || word.startsWith('<') || word.startsWith('-') || word.includes('|') || (/[A-Z]/.test(word) && word === word.toUpperCase());
44
+ // Builds an entry from a command signature such as "agents run [CARD] [--from STEP]".
45
+ export function makeEntry(text, desc, dynamic = false) {
46
+ const words = text.trim().split(/\s+/);
47
+ const at = words.findIndex(isPlaceholder);
48
+ const prefixWords = at === -1 ? words : words.slice(0, at);
49
+ const rest = at === -1 ? '' : words.slice(at).join(' ');
50
+ // Runnable as the prefix when everything after it is optional.
51
+ const runnable = rest.replace(/\[[^\]]*\]/g, '').trim() === '';
52
+ return { text: text.trim(), desc, prefix: prefixWords.join(' '), runnable: dynamic || runnable, dynamic };
53
+ }
54
+ // Pulls command lines out of a tool's captured help. Three shapes exist:
55
+ // "Usage: vsf awake on [..]", " status Description", and
56
+ // " status [--json]\n Description".
57
+ export function parseHelpLines(tool, text) {
58
+ const lines = text.split('\n');
59
+ const out = [];
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ const full = new RegExp(`^\\s*(?:Usage:\\s*)?vsf ${tool} (.+)$`).exec(line);
63
+ if (full) {
64
+ const [sig, desc] = full[1].trim().split(/\s{2,}/);
65
+ if (!sig.startsWith('<'))
66
+ out.push(makeEntry(`${tool} ${sig}`, desc));
67
+ continue;
68
+ }
69
+ if (/^ {2}\S/.test(line)) {
70
+ const [sig, desc] = line.trim().split(/\s{2,}/);
71
+ let d = desc;
72
+ const next = lines[i + 1];
73
+ if (!d && next && /^ {4,}\S/.test(next))
74
+ d = next.trim();
75
+ if (sig && !sig.startsWith('<') && !sig.startsWith('vsf '))
76
+ out.push(makeEntry(`${tool} ${sig}`, d));
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ // Command lines from the umbrella usage table (commands/help.ts).
82
+ export function parseUsages(usages) {
83
+ const out = [];
84
+ for (const usage of Object.values(usages)) {
85
+ const lines = usage.split('\n');
86
+ const hits = [];
87
+ for (const line of lines) {
88
+ const m = /^\s*(?:Usage:\s*)?vsf (.+)$/.exec(line);
89
+ if (!m)
90
+ continue;
91
+ const [sig, desc] = m[1].trim().split(/\s{2,}/);
92
+ hits.push({ sig: sig, desc });
93
+ }
94
+ // One-line usages describe themselves on the next paragraph.
95
+ const prose = lines.find((l, i) => i > 0 && l.trim() && !/^\s*(Usage:\s*)?vsf /.test(l) && !/^\s{2,}/.test(l));
96
+ for (const hit of hits)
97
+ out.push(makeEntry(hit.sig, hit.desc ?? (hits.length === 1 ? prose : undefined)));
98
+ }
99
+ return out;
100
+ }
101
+ // Picks that fill in card names from live data.
102
+ export function dynamicEntries(state) {
103
+ const out = [];
104
+ const seen = new Set();
105
+ const add = (text, desc) => {
106
+ if (!seen.has(text)) {
107
+ seen.add(text);
108
+ out.push(makeEntry(text, desc, true));
109
+ }
110
+ };
111
+ const nameOf = (id) => state.steps?.steps.find((s) => s.id === id)?.name ?? id.replace(/^vs-/, '');
112
+ for (const p of state.agents?.parked ?? [])
113
+ add(`agents retry ${p.card}`, 'queue the parked card again');
114
+ for (const c of state.agents?.interrupted ?? [])
115
+ add(`agents retry ${c}`, 'queue the interrupted card again');
116
+ const live = state.agents?.current?.card;
117
+ if (live)
118
+ add(`agents cancel ${live}`, 'stop the card being worked on');
119
+ const cards = new Set();
120
+ for (const r of state.runs) {
121
+ if (cards.size >= 6 && !cards.has(r.card))
122
+ continue;
123
+ cards.add(r.card);
124
+ const rec = r.record;
125
+ if (!rec)
126
+ continue;
127
+ if (!seen.has(`agents show ${r.card}`))
128
+ add(`agents show ${r.card}`, 'steps, outcome and labels of the latest run');
129
+ if (rec.outcome === 'cancelled')
130
+ add(`agents retry ${r.card}`, 'queue the cancelled card again');
131
+ const last = rec.steps[rec.steps.length - 1];
132
+ if (last && (rec.outcome === 'parked' || rec.outcome === 'cancelled'))
133
+ add(`agents run ${r.card} --from ${nameOf(last.step)}`, 'run again from that step');
134
+ }
135
+ return out;
136
+ }
137
+ const norm = (s) => s.toLowerCase().trim().replace(/\s+/g, ' ');
138
+ // Entries matching what was typed, best first. Every typed word has to appear
139
+ // in the command (or, failing that, its description).
140
+ export function filterPalette(entries, query) {
141
+ const q = norm(query.replace(/^:/, ''));
142
+ if (!q)
143
+ return entries;
144
+ const words = q.split(' ');
145
+ const scored = [];
146
+ entries.forEach((e, i) => {
147
+ const text = norm(e.text);
148
+ if (text.startsWith(q))
149
+ scored.push({ e, score: 0, i });
150
+ else if (words.every((w) => text.includes(w)))
151
+ scored.push({ e, score: 1, i });
152
+ else if (words.every((w) => norm(`${e.text} ${e.desc ?? ''}`).includes(w)))
153
+ scored.push({ e, score: 2, i });
154
+ });
155
+ return scored.sort((a, b) => a.score - b.score || a.i - b.i).map((s) => s.e);
156
+ }
157
+ // What Tab puts in the input for an entry.
158
+ export function completion(entry) {
159
+ return entry.dynamic ? entry.text : entry.prefix + ' ';
160
+ }
161
+ export function onEnter(entry, query) {
162
+ const typed = splitCommand(query.replace(/^:/, ''));
163
+ if (!entry)
164
+ return typed.length ? { run: typed } : { none: true };
165
+ if (entry.dynamic)
166
+ return { run: splitCommand(entry.text) };
167
+ const prefix = splitCommand(entry.prefix);
168
+ // Words typed after the command's own: they are the card, name or flags.
169
+ if (norm(query).startsWith(norm(entry.prefix)) && typed.length > prefix.length)
170
+ return { run: typed };
171
+ if (entry.runnable)
172
+ return { run: prefix };
173
+ // A command that needs a card or a name: complete it, the user types the rest.
174
+ return { fill: completion(entry) };
175
+ }
176
+ const terminalNote = (e) => (needsTerminal(splitCommand(e.dynamic ? e.text : e.prefix)) ? 'opens in the terminal' : '');
177
+ const MAX_ROWS = 8;
178
+ export function Palette(props) {
179
+ const data = useData();
180
+ const [query, setQuery] = useState('');
181
+ const [cursor, setCursor] = useState(0);
182
+ const [sel, setSel] = useState(0);
183
+ const [toolEntries, setToolEntries] = useState([]);
184
+ const [result, setResult] = useState(null);
185
+ const tools = data.state.tools.join(',');
186
+ useEffect(() => {
187
+ let live = true;
188
+ void Promise.all(data.state.tools.map(async (t) => parseHelpLines(t, await data.help(t)))).then((all) => {
189
+ if (live)
190
+ setToolEntries(all.flat());
191
+ });
192
+ return () => {
193
+ live = false;
194
+ };
195
+ }, [tools]);
196
+ const all = useMemo(() => {
197
+ const seen = new Set();
198
+ return [...dynamicEntries(data.state), ...props.umbrella, ...toolEntries].filter((e) => !seen.has(e.text) && !!seen.add(e.text));
199
+ }, [data.state.agents, data.state.runs, data.state.steps, props.umbrella, toolEntries]);
200
+ const matches = useMemo(() => filterPalette(all, query), [all, query]);
201
+ const index = Math.min(sel, Math.max(0, matches.length - 1));
202
+ const entry = matches[index];
203
+ const typed = splitCommand(query);
204
+ useInput((input, key) => {
205
+ if (result) {
206
+ if (key.escape || key.return || input === 'q')
207
+ props.onClose();
208
+ return;
209
+ }
210
+ if (key.escape)
211
+ return props.onClose();
212
+ if (key.upArrow)
213
+ return setSel(Math.max(0, index - 1));
214
+ if (key.downArrow)
215
+ return setSel(Math.min(matches.length - 1, index + 1));
216
+ if (key.tab) {
217
+ if (entry) {
218
+ const v = completion(entry);
219
+ setQuery(v);
220
+ setCursor(v.length);
221
+ setSel(0);
222
+ }
223
+ return;
224
+ }
225
+ if (key.return) {
226
+ const act = onEnter(entry, query);
227
+ if ('fill' in act) {
228
+ setQuery(act.fill);
229
+ setCursor(act.fill.length);
230
+ setSel(0);
231
+ }
232
+ else if ('run' in act) {
233
+ const args = act.run;
234
+ if (needsTerminal(args)) {
235
+ props.onClose();
236
+ props.onTerminal(args);
237
+ }
238
+ else {
239
+ setResult({ args, code: null, output: '' });
240
+ void props.onRun(args).then((r) => setResult({ args, code: r.code, output: r.output }));
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ const r = editText(query, cursor, input, key);
246
+ setCursor(r.cursor);
247
+ if (r.value !== query) {
248
+ setQuery(r.value);
249
+ setSel(0);
250
+ }
251
+ });
252
+ if (result) {
253
+ const lines = result.output ? result.output.split('\n') : [];
254
+ const shown = lines.slice(-14);
255
+ return h(Panel, { title: ': command', focused: true }, h(Text, { color: theme.accent }, `vsf ${result.args.join(' ')}`), h(Text, null, ' '), result.code === null ? h(Text, { color: theme.dim }, 'Running...') : null, ...shown.map((l, i) => h(Text, { key: i, wrap: 'truncate-end' }, l || ' ')), lines.length > shown.length ? h(Text, { color: theme.dim }, `(${lines.length - shown.length} earlier lines hidden)`) : null, result.code !== null
256
+ ? h(Text, { color: result.code === 0 ? theme.green : theme.red }, result.code === 0 ? (lines.length ? ' ' : 'Done.') : `Exited with code ${result.code}.`)
257
+ : null, h(Text, { color: theme.dim }, 'esc or enter closes'));
258
+ }
259
+ const at = Math.min(cursor, query.length);
260
+ const start = Math.max(0, Math.min(index - MAX_ROWS + 1, matches.length - MAX_ROWS));
261
+ const visible = matches.slice(start, start + MAX_ROWS);
262
+ const width = Math.max(...visible.map((e) => e.text.length), 20);
263
+ return h(Panel, { title: ': command', focused: true }, h(Text, null, h(Text, { color: theme.dim }, ':'), h(Text, null, query.slice(0, at)), h(Text, { inverse: true }, query[at] ?? ' '), h(Text, null, query.slice(at + 1))), h(Text, null, ' '), ...visible.map((e, i) => {
264
+ const on = start + i === index;
265
+ return h(Text, { key: e.text, wrap: 'truncate-end', backgroundColor: on ? theme.selected : undefined, bold: on }, `${on ? '▶ ' : ' '}${e.text.padEnd(Math.min(width, 44))} `, h(Text, { color: theme.dim, bold: false }, [e.desc, terminalNote(e)].filter(Boolean).join(' · ')));
266
+ }), matches.length === 0 && typed.length
267
+ ? h(Text, { backgroundColor: theme.selected, bold: true }, `▶ ${query.trim()}`, h(Text, { color: theme.dim, bold: false }, needsTerminal(typed) ? ' run as typed, opens in the terminal' : ' run as typed'))
268
+ : null, matches.length > visible.length ? h(Text, { color: theme.dim }, ` ${matches.length - visible.length} more, keep typing`) : null, h(Box, { marginTop: 1 }, h(Text, { color: theme.dim }, 'Every vsf command is here, with the same flags. Picks fill in card and workflow names.')));
269
+ }
@@ -0,0 +1 @@
1
+ export declare function runDashboard(): Promise<number>;
@@ -0,0 +1,20 @@
1
+ // Opens the dashboard. Loaded with a dynamic import only when it is needed, so
2
+ // plain commands never load React or Ink.
3
+ import React from 'react';
4
+ import { render } from 'ink';
5
+ import { App } from "./app.js";
6
+ export async function runDashboard() {
7
+ const instance = render(React.createElement(App), {
8
+ alternateScreen: true,
9
+ exitOnCtrlC: true,
10
+ // Anything a tool prints through console is routed above the UI, not into it.
11
+ patchConsole: true,
12
+ });
13
+ try {
14
+ await instance.waitUntilExit();
15
+ return 0;
16
+ }
17
+ finally {
18
+ instance.cleanup();
19
+ }
20
+ }
@@ -0,0 +1,6 @@
1
+ import { type Screen } from '../screens.ts';
2
+ export declare function nextTab(index: number, count: number, input: string, key: {
3
+ leftArrow?: boolean;
4
+ rightArrow?: boolean;
5
+ }): number | null;
6
+ export declare const agentsScreen: Screen;
@@ -0,0 +1,44 @@
1
+ // The agents shell: a tab bar (Runs, Workflows, Projects, Homes, Settings) over
2
+ // the screens with those ids. Left and right or 1 to 5 switch tabs. The Runs
3
+ // tab is built in runs.ts; the others are rendered by id from the registry.
4
+ import React from 'react';
5
+ import { Box, useInput } from 'ink';
6
+ import { useScreen } from "../context.js";
7
+ import { AGENT_TABS, ScreenHost, activeTab, screens } from "../screens.js";
8
+ import { SubTabs } from "../widgets.js";
9
+ import { tabLock } from "./runs.js";
10
+ const h = React.createElement;
11
+ const TAB_HINT = { key: '←→', label: 'tab' };
12
+ const BACK = { key: 'esc', label: 'back' };
13
+ // The tab a key leads to, or null. `index` is the current tab.
14
+ export function nextTab(index, count, input, key) {
15
+ if (key.rightArrow)
16
+ return (index + 1) % count;
17
+ if (key.leftArrow)
18
+ return (index + count - 1) % count;
19
+ const n = Number(input);
20
+ return /^[1-9]$/.test(input) && n <= count ? n - 1 : null;
21
+ }
22
+ function AgentsShell(props) {
23
+ const ctx = useScreen();
24
+ const tab = activeTab(props.params);
25
+ useInput((input, key) => {
26
+ if (tabLock.on)
27
+ return;
28
+ const to = nextTab(AGENT_TABS.indexOf(tab), AGENT_TABS.length, input, key);
29
+ const next = to === null ? undefined : AGENT_TABS[to];
30
+ if (next && next.id !== tab.id)
31
+ ctx.nav.replace('agents', { ...props.params, tab: next.id });
32
+ }, { isActive: !ctx.modalOpen && !ctx.capturing });
33
+ return h(Box, { flexDirection: 'column' }, h(SubTabs, { tabs: AGENT_TABS.map((t) => ({ id: t.id, label: t.label })), active: tab.id }), h(Box, { height: 1 }), h(ScreenHost, { key: tab.id, id: tab.id, params: { ...props.params, ...tab.params }, extraHints: [TAB_HINT] }));
34
+ }
35
+ export const agentsScreen = {
36
+ id: 'agents',
37
+ title: (p) => ['Agents', activeTab(p).label],
38
+ component: AgentsShell,
39
+ hints: (p) => {
40
+ const tab = activeTab(p);
41
+ const inner = screens[tab.id];
42
+ return [...(inner ? inner.hints({ params: { ...p, ...tab.params } }) : [BACK]), TAB_HINT];
43
+ },
44
+ };
@@ -0,0 +1,23 @@
1
+ import { type AwakeStatus } from '../data.ts';
2
+ import { type Line } from '../widgets.ts';
3
+ import type { Screen } from '../screens.ts';
4
+ export type AwakeFull = AwakeStatus & {
5
+ heat?: boolean;
6
+ revertOnReboot?: boolean;
7
+ };
8
+ export declare const logFile: (dir?: string) => string;
9
+ export declare function readTail(path: string, maxBytes?: number): string;
10
+ export declare function statusLines(a: AwakeFull): Line[];
11
+ export interface OnForm {
12
+ mode: 'until' | 'for';
13
+ value: string;
14
+ battery: string;
15
+ }
16
+ export declare const emptyForm: () => OnForm;
17
+ export declare function onArgs(f: OnForm): {
18
+ args: string[];
19
+ } | {
20
+ error: string;
21
+ };
22
+ export declare function formSummary(f: OnForm): string;
23
+ export declare const awakeScreen: Screen;
@@ -0,0 +1,209 @@
1
+ // Awake: the full status, the last lines of the watchdog log, and the three
2
+ // things you do with it (turn on, turn off, read the whole log). Each action
3
+ // runs the real command: vsf awake on / off.
4
+ import React, { useEffect, useState } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { configDir } from "../../config.js";
9
+ import { useData } from "../data.js";
10
+ import { useBackGuard, useEquivalent, useHints, useKeys, useScreen } from "../context.js";
11
+ import { clock, minutesText } from "../format.js";
12
+ import { theme } from "../theme.js";
13
+ import { Lines, Panel, Select, seg, TextInput } from "../widgets.js";
14
+ import { scrollBy, tailLines } from "./list-window.js";
15
+ const h = React.createElement;
16
+ export const logFile = (dir = configDir()) => join(dir, 'awake', 'watchdog.log');
17
+ // The tail of a file without reading all of a huge log.
18
+ export function readTail(path, maxBytes = 256 * 1024) {
19
+ try {
20
+ if (!existsSync(path))
21
+ return '';
22
+ const size = statSync(path).size;
23
+ const start = Math.max(0, size - maxBytes);
24
+ const fd = openSync(path, 'r');
25
+ try {
26
+ const buf = Buffer.alloc(size - start);
27
+ readSync(fd, buf, 0, buf.length, start);
28
+ return buf.toString('utf8');
29
+ }
30
+ finally {
31
+ closeSync(fd);
32
+ }
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ // ---- the status block -----------------------------------------------------
39
+ export function statusLines(a) {
40
+ const label = (t) => seg(t.padEnd(12), theme.dim);
41
+ const power = a.power.onBattery ? `on battery, ${a.power.percent ?? '?'}%` : 'on power adapter';
42
+ const lines = [];
43
+ if (a.on)
44
+ lines.push([seg('● ', theme.green), seg(`On since ${a.since ? clock(new Date(a.since)) : '?'}, the lid can close`, undefined, { bold: true })]);
45
+ else if (a.sleepDisabledByOther)
46
+ lines.push([seg('● ', theme.yellow), seg('Sleep is off, but not by VoidShift')]);
47
+ else
48
+ lines.push([seg('○ ', theme.dim), seg('Off. The Mac sleeps normally.')]);
49
+ lines.push([]);
50
+ if (a.on) {
51
+ lines.push([label('Until'), seg(a.until ? `${clock(new Date(a.until))}${a.minutesLeft !== null ? ` (${minutesText(a.minutesLeft)} left)` : ''}` : 'no end time')]);
52
+ }
53
+ lines.push([label('Battery'), seg(`floor ${a.batteryFloor}%, `), a.floorApplies ? seg('applies now (on battery)') : seg('does not apply while on power', theme.dim)]);
54
+ lines.push([label('Power'), seg(power)]);
55
+ lines.push([label('Watchdog'), a.watchdog ? seg('ok, checks every minute', theme.green) : seg('not loaded, guardrails will not act. vsf setup awake', theme.yellow)]);
56
+ if (a.lastReason)
57
+ lines.push([label('Last ended'), seg(a.lastReason)]);
58
+ lines.push([
59
+ label('Guardrails'),
60
+ seg(`heat stop ${a.heat === false ? 'off' : 'on'}`, a.heat === false ? theme.yellow : undefined),
61
+ seg(' · '),
62
+ seg(`turn off after reboot ${a.revertOnReboot === false ? 'off' : 'on'}`, a.revertOnReboot === false ? theme.yellow : undefined),
63
+ ]);
64
+ return lines;
65
+ }
66
+ export const emptyForm = () => ({ mode: 'until', value: '', battery: '' });
67
+ // The vsf awake on arguments, or what is wrong with the form.
68
+ export function onArgs(f) {
69
+ const args = ['awake', 'on'];
70
+ const value = f.value.trim();
71
+ if (value)
72
+ args.push(f.mode === 'until' ? '--until' : '--for', value);
73
+ const battery = f.battery.trim();
74
+ if (battery) {
75
+ if (!/^\d+$/.test(battery) || Number(battery) < 5 || Number(battery) > 95)
76
+ return { error: 'Battery takes a whole percentage from 5 to 95.' };
77
+ args.push('--battery', battery);
78
+ }
79
+ return { args };
80
+ }
81
+ // A short plain-English line of what the form will do.
82
+ export function formSummary(f) {
83
+ const value = f.value.trim();
84
+ const when = !value ? 'with the default time limit' : f.mode === 'until' ? `until ${value}` : `for ${value}`;
85
+ return `Keep the Mac awake ${when}${f.battery.trim() ? `, stopping at ${f.battery.trim()}% battery` : ''}.`;
86
+ }
87
+ const FIELDS = ['mode', 'value', 'battery'];
88
+ function OnFormView(props) {
89
+ const ctx = useScreen();
90
+ const { form, setForm } = props;
91
+ const [field, setField] = useState(0);
92
+ const [error, setError] = useState('');
93
+ const parsed = onArgs(form);
94
+ const submit = async () => {
95
+ if ('error' in parsed) {
96
+ setError(parsed.error);
97
+ return;
98
+ }
99
+ const result = await ctx.actions.run({
100
+ args: parsed.args,
101
+ title: 'Turn awake on?',
102
+ body: `${formSummary(form)} The lid can close and the Mac keeps running. The watchdog turns it off at the limit.`,
103
+ confirmLabel: 'Turn on',
104
+ cancelLabel: 'Not now',
105
+ });
106
+ if (result && result.code === 0)
107
+ props.onClose();
108
+ };
109
+ useKeys((input, key) => {
110
+ if (key.escape)
111
+ props.onClose();
112
+ else if (key.tab)
113
+ setField((i) => (i + (key.shift ? FIELDS.length - 1 : 1)) % FIELDS.length);
114
+ else if (key.ctrl && input === 's')
115
+ void submit();
116
+ }, { whileCapturing: true });
117
+ const row = (i, name, node, note) => h(Box, { key: name, flexDirection: 'row' }, h(Text, { color: field === i ? theme.accent : theme.dim }, name.padEnd(15)), h(Box, { minWidth: 18 }, node), note ? h(Text, { color: theme.dim }, ` ${note}`) : null);
118
+ return h(Panel, { title: 'Turn awake on', focused: true }, row(0, 'Stop', h(Select, { focus: field === 0, value: form.mode, onChange: (v) => setForm({ ...form, mode: v === 'for' ? 'for' : 'until' }), options: [{ value: 'until', label: 'At a time' }, { value: 'for', label: 'After a while' }] })), row(1, form.mode === 'until' ? 'Until' : 'For', h(TextInput, { focus: field === 1, value: form.value, onChange: (v) => setForm({ ...form, value: v }), placeholder: form.mode === 'until' ? '7am or 19:30' : '6h or 90m', onSubmit: () => void submit() }), 'leave empty for the default'), row(2, 'Battery floor', h(TextInput, { focus: field === 2, value: form.battery, onChange: (v) => setForm({ ...form, battery: v }), placeholder: '20', onSubmit: () => void submit() }), '5 to 95, stops here when on battery'), h(Box, { marginTop: 1 }, h(Text, { color: error ? theme.red : theme.dim }, error || formSummary(form))));
119
+ }
120
+ // ---- the screen -----------------------------------------------------------
121
+ function Awake() {
122
+ const ctx = useScreen();
123
+ const { state } = useData();
124
+ const [mode, setMode] = useState('main');
125
+ const [log, setLog] = useState('');
126
+ const [offset, setOffset] = useState(0);
127
+ const [form, setForm] = useState(emptyForm());
128
+ const a = state.awake;
129
+ // Refreshed live: read the log again every second and after each poll.
130
+ useEffect(() => {
131
+ const read = () => setLog(readTail(logFile()));
132
+ read();
133
+ const t = setInterval(read, 1000);
134
+ return () => clearInterval(t);
135
+ }, []);
136
+ const size = Math.max(5, ctx.rows - 8);
137
+ const maxOffset = Math.max(0, tailLines(log, 100000).length - size);
138
+ useBackGuard(() => {
139
+ if (mode === 'main')
140
+ return false;
141
+ setMode('main');
142
+ return true;
143
+ });
144
+ useHints(mode === 'form'
145
+ ? [{ key: 'tab', label: 'next field' }, { key: 'enter', label: 'turn on' }, { key: 'esc', label: 'cancel' }]
146
+ : mode === 'log'
147
+ ? [{ key: '↑↓', label: 'scroll' }, { key: 'G', label: 'latest' }, { key: 'l', label: 'close log' }, { key: 'esc', label: 'back' }]
148
+ : [{ key: 'o', label: 'turn on' }, { key: 'f', label: 'turn off' }, { key: 'l', label: 'full log' }, { key: 'esc', label: 'back' }]);
149
+ const formArgs = onArgs(form);
150
+ useEquivalent(mode === 'main' ? ['awake', 'status'] : mode === 'form' ? ('args' in formArgs ? formArgs.args : ['awake', 'on']) : null);
151
+ useKeys((input, key) => {
152
+ if (mode === 'form')
153
+ return;
154
+ if (mode === 'log') {
155
+ const total = tailLines(log, 100000).length;
156
+ if (key.upArrow)
157
+ setOffset((o) => scrollBy(o, 1, maxOffset));
158
+ else if (key.downArrow)
159
+ setOffset((o) => scrollBy(o, -1, maxOffset));
160
+ else if (key.pageUp)
161
+ setOffset((o) => scrollBy(o, size - 1, maxOffset));
162
+ else if (key.pageDown)
163
+ setOffset((o) => scrollBy(o, -(size - 1), maxOffset));
164
+ else if (input === 'G')
165
+ setOffset(0);
166
+ else if (input === 'g')
167
+ setOffset(Math.max(0, total - size));
168
+ else if (input === 'l')
169
+ setMode('main');
170
+ return;
171
+ }
172
+ if (input === 'o') {
173
+ setForm(emptyForm());
174
+ setMode('form');
175
+ }
176
+ else if (input === 'f') {
177
+ void ctx.actions.run({
178
+ args: ['awake', 'off'],
179
+ title: 'Turn awake off?',
180
+ body: 'The Mac goes back to sleeping normally when the lid closes.',
181
+ confirmLabel: 'Turn off',
182
+ cancelLabel: 'Keep awake',
183
+ });
184
+ }
185
+ else if (input === 'l') {
186
+ setOffset(0);
187
+ setMode('log');
188
+ }
189
+ });
190
+ if (!a) {
191
+ return h(Box, { paddingX: 1 }, h(Text, { color: state.errors.awake ? theme.red : theme.dim }, state.errors.awake ?? (state.loaded ? 'awake is not added. Run vsf add awake.' : 'Loading...')));
192
+ }
193
+ if (mode === 'form')
194
+ return h(OnFormView, { form, setForm, onClose: () => setMode('main') });
195
+ if (mode === 'log') {
196
+ const lines = tailLines(log, 100000);
197
+ const end = lines.length - offset;
198
+ const shown = lines.slice(Math.max(0, end - size), end);
199
+ return h(Panel, { title: 'Watchdog log', focused: true, subtitle: offset ? `${offset} lines above the end` : 'following', flexGrow: 1 }, shown.length ? h(Lines, { lines: shown.map((l) => [seg(l, theme.text)]) }) : h(Text, { color: theme.dim }, 'No watchdog log yet.'));
200
+ }
201
+ const last = tailLines(log, 20);
202
+ return h(Box, { flexDirection: 'column' }, h(Panel, { title: 'Awake' }, h(Lines, { lines: statusLines(a) })), h(Panel, { title: 'Watchdog log', subtitle: 'last 20 lines' }, last.length ? h(Lines, { lines: last.map((l) => [seg(l, theme.dim)]) }) : h(Text, { color: theme.dim }, 'No watchdog log yet.')));
203
+ }
204
+ export const awakeScreen = {
205
+ id: 'awake',
206
+ title: 'Awake',
207
+ component: () => h(Awake),
208
+ hints: () => [{ key: 'o', label: 'turn on' }, { key: 'f', label: 'turn off' }, { key: 'l', label: 'full log' }, { key: 'esc', label: 'back' }],
209
+ };
@@ -0,0 +1,11 @@
1
+ import { type Cell } from '../widgets.ts';
2
+ import type { Check } from '../../tool.ts';
3
+ import type { Screen } from '../screens.ts';
4
+ export declare function sortChecks(checks: Check[]): Check[];
5
+ export declare const mark: (s: Check["status"]) => {
6
+ text: string;
7
+ colour: string;
8
+ };
9
+ export declare function fixCommand(fix: string | undefined): string[] | null;
10
+ export declare function checkRows(checks: Check[]): Cell[][];
11
+ export declare const checksScreen: Screen;