@voidlabs.gg/voidshift 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +134 -2
  3. package/dist/checks.d.ts +9 -0
  4. package/dist/checks.js +51 -0
  5. package/dist/commands/args.d.ts +13 -0
  6. package/dist/commands/args.js +22 -0
  7. package/dist/commands/config.d.ts +1 -0
  8. package/dist/commands/config.js +181 -0
  9. package/dist/commands/doctor.d.ts +3 -0
  10. package/dist/commands/doctor.js +52 -0
  11. package/dist/commands/help.d.ts +3 -0
  12. package/dist/commands/help.js +59 -0
  13. package/dist/commands/setup.d.ts +2 -0
  14. package/dist/commands/setup.js +135 -0
  15. package/dist/commands/status.d.ts +2 -0
  16. package/dist/commands/status.js +60 -0
  17. package/dist/commands/tools.d.ts +9 -0
  18. package/dist/commands/tools.js +111 -0
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/config.d.ts +9 -0
  22. package/dist/config.js +29 -0
  23. package/dist/dotted.d.ts +9 -0
  24. package/dist/dotted.js +86 -0
  25. package/dist/loader.d.ts +6 -0
  26. package/dist/loader.js +51 -0
  27. package/dist/main.d.ts +2 -0
  28. package/dist/main.js +93 -0
  29. package/dist/registry.d.ts +8 -0
  30. package/dist/registry.js +26 -0
  31. package/dist/tool.d.ts +51 -0
  32. package/dist/tool.js +4 -0
  33. package/dist/tui/actions.d.ts +17 -0
  34. package/dist/tui/actions.js +99 -0
  35. package/dist/tui/app.d.ts +6 -0
  36. package/dist/tui/app.js +246 -0
  37. package/dist/tui/context.d.ts +62 -0
  38. package/dist/tui/context.js +48 -0
  39. package/dist/tui/data.d.ts +196 -0
  40. package/dist/tui/data.js +302 -0
  41. package/dist/tui/format.d.ts +11 -0
  42. package/dist/tui/format.js +54 -0
  43. package/dist/tui/home.d.ts +51 -0
  44. package/dist/tui/home.js +336 -0
  45. package/dist/tui/keys.d.ts +2 -0
  46. package/dist/tui/keys.js +107 -0
  47. package/dist/tui/nav.d.ts +24 -0
  48. package/dist/tui/nav.js +32 -0
  49. package/dist/tui/palette.d.ts +32 -0
  50. package/dist/tui/palette.js +269 -0
  51. package/dist/tui/run.d.ts +1 -0
  52. package/dist/tui/run.js +20 -0
  53. package/dist/tui/screens/agents.d.ts +6 -0
  54. package/dist/tui/screens/agents.js +44 -0
  55. package/dist/tui/screens/awake.d.ts +23 -0
  56. package/dist/tui/screens/awake.js +209 -0
  57. package/dist/tui/screens/checks.d.ts +11 -0
  58. package/dist/tui/screens/checks.js +106 -0
  59. package/dist/tui/screens/homes.d.ts +14 -0
  60. package/dist/tui/screens/homes.js +99 -0
  61. package/dist/tui/screens/list-window.d.ts +6 -0
  62. package/dist/tui/screens/list-window.js +18 -0
  63. package/dist/tui/screens/log.d.ts +26 -0
  64. package/dist/tui/screens/log.js +266 -0
  65. package/dist/tui/screens/memory.d.ts +24 -0
  66. package/dist/tui/screens/memory.js +174 -0
  67. package/dist/tui/screens/project-form.d.ts +14 -0
  68. package/dist/tui/screens/project-form.js +137 -0
  69. package/dist/tui/screens/projects.d.ts +17 -0
  70. package/dist/tui/screens/projects.js +109 -0
  71. package/dist/tui/screens/runs.d.ts +43 -0
  72. package/dist/tui/screens/runs.js +420 -0
  73. package/dist/tui/screens/settings.d.ts +17 -0
  74. package/dist/tui/screens/settings.js +153 -0
  75. package/dist/tui/screens/workflow-form.d.ts +58 -0
  76. package/dist/tui/screens/workflow-form.js +375 -0
  77. package/dist/tui/screens/workflows.d.ts +41 -0
  78. package/dist/tui/screens/workflows.js +174 -0
  79. package/dist/tui/screens.d.ts +30 -0
  80. package/dist/tui/screens.js +172 -0
  81. package/dist/tui/theme.d.ts +14 -0
  82. package/dist/tui/theme.js +18 -0
  83. package/dist/tui/widgets.d.ts +122 -0
  84. package/dist/tui/widgets.js +264 -0
  85. package/dist/ui.d.ts +5 -0
  86. package/dist/ui.js +82 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.js +17 -0
  89. package/package.json +59 -4
@@ -0,0 +1,106 @@
1
+ // Checks: every doctor check with its mark and its fix. Enter on a check whose
2
+ // fix is a vsf command runs that command (after a confirm); other fixes are
3
+ // shown as text.
4
+ import React, { useState } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import { needsTerminal, commandLine } from "../actions.js";
7
+ import { useData } from "../data.js";
8
+ import { useEquivalent, useHints, useKeys, useScreen } from "../context.js";
9
+ import { clock } from "../format.js";
10
+ import { theme } from "../theme.js";
11
+ import { Panel, Table } from "../widgets.js";
12
+ const h = React.createElement;
13
+ const ORDER = { fail: 0, warn: 1, ok: 2 };
14
+ // Problems first (failed, then warnings), then the ones that are fine, each in its own order.
15
+ export function sortChecks(checks) {
16
+ return checks.map((c, i) => ({ c, i })).sort((a, b) => ORDER[a.c.status] - ORDER[b.c.status] || a.i - b.i).map((x) => x.c);
17
+ }
18
+ export const mark = (s) => s === 'ok' ? { text: '✓', colour: theme.green } : s === 'warn' ? { text: '!', colour: theme.yellow } : { text: '✗', colour: theme.red };
19
+ // The vsf arguments in a fix like "vsf setup awake", or null when it is not a plain vsf command.
20
+ export function fixCommand(fix) {
21
+ if (!fix)
22
+ return null;
23
+ const words = fix.trim().split(/\s+/);
24
+ if (words[0] !== 'vsf' || words.length < 2)
25
+ return null;
26
+ if (words.some((w) => /[|&;<>`$()'"\\]/.test(w)))
27
+ return null;
28
+ return words.slice(1);
29
+ }
30
+ export function checkRows(checks) {
31
+ return checks.map((c) => {
32
+ const m = mark(c.status);
33
+ return [{ text: m.text, colour: m.colour }, { text: c.name, bold: c.status !== 'ok' }, { text: c.detail, colour: c.status === 'ok' ? theme.dim : undefined }, { text: c.fix ?? '', colour: theme.dim }];
34
+ });
35
+ }
36
+ function Checks() {
37
+ const ctx = useScreen();
38
+ const { state, refreshChecks } = useData();
39
+ const checks = sortChecks(state.checks ?? []);
40
+ const [sel, setSel] = useState(0);
41
+ const at = Math.min(sel, Math.max(0, checks.length - 1));
42
+ const check = checks[at];
43
+ const args = fixCommand(check?.fix);
44
+ useHints([
45
+ { key: 'enter', label: args ? 'run the fix' : 'show the fix' },
46
+ { key: 'r', label: 'check again' },
47
+ { key: 'esc', label: 'back' },
48
+ ]);
49
+ useEquivalent(args);
50
+ useKeys((input, key) => {
51
+ if (key.upArrow)
52
+ setSel(Math.max(0, at - 1));
53
+ else if (key.downArrow)
54
+ setSel(Math.min(checks.length - 1, at + 1));
55
+ else if (input === 'r') {
56
+ refreshChecks();
57
+ ctx.toast('Checking again', 'info');
58
+ }
59
+ else if (key.return && check) {
60
+ if (!check.fix)
61
+ ctx.toast(`${check.name}: ${check.detail}. No fix needed.`, 'info');
62
+ else if (!args)
63
+ ctx.toast(`Fix for ${check.name}: ${check.fix}`, 'info');
64
+ else if (needsTerminal(args)) {
65
+ void (async () => {
66
+ const ok = await ctx.actions.confirm({
67
+ title: `Run ${commandLine(args)}?`,
68
+ body: `Fixes ${check.name}. This opens in the terminal, and you come back here when it is done.`,
69
+ confirmLabel: 'Run it',
70
+ cancelLabel: 'Not now',
71
+ });
72
+ if (ok) {
73
+ await ctx.actions.terminal(args);
74
+ refreshChecks();
75
+ }
76
+ })();
77
+ }
78
+ else {
79
+ void ctx.actions
80
+ .run({ args, title: `Run ${commandLine(args)}?`, body: `Fixes ${check.name}: ${check.detail}.`, confirmLabel: 'Run it', cancelLabel: 'Not now' })
81
+ .then((r) => r && refreshChecks());
82
+ }
83
+ }
84
+ });
85
+ if (!state.checks) {
86
+ return h(Box, { paddingX: 1 }, h(Text, { color: state.errors.checks ? theme.red : theme.dim }, state.errors.checks ?? 'Running checks...'));
87
+ }
88
+ const bad = checks.filter((c) => c.status !== 'ok').length;
89
+ return h(Box, { flexDirection: 'column' }, h(Panel, { title: 'Checks', subtitle: `${checks.length - bad} ok${bad ? `, ${bad} need a look` : ''}${state.checksAt ? ` · checked ${clock(new Date(state.checksAt))}` : ''}`, focused: true }, h(Table, {
90
+ columns: [{ title: '', width: 3 }, { title: 'CHECK', width: 24 }, { title: 'WHAT WE FOUND', width: 34 }, { title: 'FIX' }],
91
+ rows: checkRows(checks),
92
+ selected: at,
93
+ marker: true,
94
+ empty: 'No checks yet.',
95
+ })), check
96
+ ? h(Panel, { title: check.name }, h(Text, null, check.detail), check.fix
97
+ ? h(Text, { color: theme.dim }, args ? `Fix: ${commandLine(args)} (enter runs it)` : `Fix: ${check.fix}`)
98
+ : h(Text, { color: theme.dim }, check.status === 'ok' ? 'Nothing to fix.' : 'No fix suggested.'))
99
+ : null);
100
+ }
101
+ export const checksScreen = {
102
+ id: 'checks',
103
+ title: 'Checks',
104
+ component: () => h(Checks),
105
+ hints: () => [{ key: 'enter', label: 'fix' }, { key: 'r', label: 'check again' }, { key: 'esc', label: 'back' }],
106
+ };
@@ -0,0 +1,14 @@
1
+ import { type HomeItem } from '../data.ts';
2
+ import type { Screen } from '../screens.ts';
3
+ export interface HomeRow {
4
+ kind: string;
5
+ dir: string;
6
+ linked: string[];
7
+ missing: string[];
8
+ taken: string[];
9
+ }
10
+ export declare function homeRows(homes: HomeItem[]): HomeRow[];
11
+ export declare function homeSummary(r: HomeRow): string;
12
+ export declare const linkArgs: (dir: string) => string[];
13
+ export declare const unlinkArgs: (dir: string) => string[];
14
+ export declare const homesScreen: Screen;
@@ -0,0 +1,99 @@
1
+ // Agents, Homes: the Claude Code and Codex config folders the agents are
2
+ // linked into. l and u run `vsf agents link DIR` and `vsf agents unlink DIR`.
3
+ import React, { useState } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { useData } from "../data.js";
6
+ import { useEquivalent, useHints, useKeys, useScreen } from "../context.js";
7
+ import { tilde } from "../format.js";
8
+ import { theme } from "../theme.js";
9
+ import { Panel, TextInput } from "../widgets.js";
10
+ const h = React.createElement;
11
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
12
+ // The homes --json shape, made safe to draw.
13
+ export function homeRows(homes) {
14
+ return homes
15
+ .filter((x) => typeof x.dir === 'string')
16
+ .map((x) => ({ kind: typeof x.kind === 'string' ? x.kind : 'claude', dir: x.dir, linked: list(x.linked), missing: list(x.missing), taken: list(x.taken) }));
17
+ }
18
+ const short = (id) => id.replace(/^vs-/, '');
19
+ // "3 linked, 1 missing, 1 taken by something else"
20
+ export function homeSummary(r) {
21
+ const parts = [`${r.linked.length} linked`];
22
+ if (r.missing.length)
23
+ parts.push(`${r.missing.length} missing`);
24
+ if (r.taken.length)
25
+ parts.push(`${r.taken.length} taken by something else`);
26
+ return parts.join(', ');
27
+ }
28
+ export const linkArgs = (dir) => ['agents', 'link', dir];
29
+ export const unlinkArgs = (dir) => ['agents', 'unlink', dir];
30
+ const LIST_HINTS = [
31
+ { key: 'l', label: 'link a folder' },
32
+ { key: 'u', label: 'unlink' },
33
+ { key: 'esc', label: 'back' },
34
+ ];
35
+ function Homes() {
36
+ const ctx = useScreen();
37
+ const { state } = useData();
38
+ const rows = homeRows(state.homes);
39
+ const [sel, setSel] = useState(0);
40
+ const [adding, setAdding] = useState(false);
41
+ const [path, setPath] = useState('');
42
+ const index = Math.max(0, Math.min(sel, rows.length - 1));
43
+ const current = rows[index];
44
+ useHints(adding ? [{ key: 'enter', label: 'link' }, { key: 'esc', label: 'cancel' }] : LIST_HINTS);
45
+ useEquivalent(adding ? (path.trim() ? linkArgs(path.trim()) : null) : current ? unlinkArgs(current.dir) : null);
46
+ const link = async () => {
47
+ const dir = path.trim();
48
+ if (!dir)
49
+ return;
50
+ const r = await ctx.actions.run({
51
+ args: linkArgs(dir),
52
+ title: 'Link the agents into this folder?',
53
+ body: `${tilde(dir)}\nThe agents are added as skills in it. Anything already there is left alone.`,
54
+ confirmLabel: 'Link',
55
+ done: `Linked ${tilde(dir)}`,
56
+ });
57
+ if (r && r.code === 0) {
58
+ setAdding(false);
59
+ setPath('');
60
+ }
61
+ };
62
+ useKeys((input, key) => {
63
+ if (adding) {
64
+ if (key.escape)
65
+ setAdding(false);
66
+ return;
67
+ }
68
+ if (key.upArrow || input === 'k')
69
+ setSel(Math.max(0, index - 1));
70
+ else if (key.downArrow || input === 'j')
71
+ setSel(Math.min(rows.length - 1, index + 1));
72
+ else if (input === 'l') {
73
+ setPath('');
74
+ setAdding(true);
75
+ }
76
+ else if (input === 'u' && current) {
77
+ void ctx.actions.run({
78
+ args: unlinkArgs(current.dir),
79
+ title: `Unlink ${tilde(current.dir)}?`,
80
+ body: 'The agents are removed from this folder. Other files in it stay.',
81
+ confirmLabel: 'Unlink',
82
+ done: `Unlinked ${tilde(current.dir)}`,
83
+ });
84
+ }
85
+ }, { whileCapturing: true });
86
+ return h(Panel, { title: 'Config folders', focused: true }, h(Text, { color: theme.dim }, 'Where the agents are linked, so Claude Code and Codex can use them.'), h(Text, null, ' '), rows.length === 0 ? h(Text, { color: theme.dim }, state.loaded ? 'No folders yet. Press l to link one.' : 'Loading...') : null, ...rows.map((r, i) => {
87
+ const on = i === index;
88
+ const bg = on ? theme.selected : undefined;
89
+ return h(Box, { key: r.dir, flexDirection: 'column' }, h(Text, { wrap: 'truncate-end', backgroundColor: bg }, h(Text, { color: theme.accent }, on ? '▶ ' : ' '), h(Text, { bold: on }, tilde(r.dir)), h(Text, { color: theme.dim }, ` ${r.kind} `), h(Text, { color: r.linked.length && !r.missing.length && !r.taken.length ? theme.green : theme.yellow }, homeSummary(r))), r.missing.length ? h(Text, { key: 'm', color: theme.yellow, wrap: 'truncate-end' }, ` missing: ${r.missing.map(short).join(', ')}`) : null, r.taken.length ? h(Text, { key: 't', color: theme.red, wrap: 'truncate-end' }, ` taken by something else: ${r.taken.map(short).join(', ')}`) : null);
90
+ }), adding
91
+ ? h(Box, { marginTop: 1 }, h(Text, { color: theme.dim }, 'Folder to link '), h(TextInput, { value: path, onChange: setPath, placeholder: '~/.claude', onSubmit: () => void link() }))
92
+ : null, state.errors.homes ? h(Text, { color: theme.red }, state.errors.homes) : null);
93
+ }
94
+ export const homesScreen = {
95
+ id: 'agents.homes',
96
+ title: 'Homes',
97
+ component: Homes,
98
+ hints: () => LIST_HINTS,
99
+ };
@@ -0,0 +1,6 @@
1
+ export declare function windowOf(count: number, index: number, size: number): {
2
+ start: number;
3
+ end: number;
4
+ };
5
+ export declare function tailLines(text: string, n: number): string[];
6
+ export declare const scrollBy: (offset: number, delta: number, max: number) => number;
@@ -0,0 +1,18 @@
1
+ // Pure helpers for showing a long list in a short box.
2
+ // The slice of `count` items that fits in `size` rows and keeps `index` visible.
3
+ export function windowOf(count, index, size) {
4
+ const n = Math.max(1, size);
5
+ if (count <= n)
6
+ return { start: 0, end: count };
7
+ const start = Math.max(0, Math.min(count - n, index - Math.floor(n / 2)));
8
+ return { start, end: start + n };
9
+ }
10
+ // The last `n` non-empty lines of a text.
11
+ export function tailLines(text, n) {
12
+ const lines = text.split('\n').map((l) => l.replace(/\s+$/, ''));
13
+ while (lines.length && !lines[lines.length - 1])
14
+ lines.pop();
15
+ return lines.slice(-n);
16
+ }
17
+ // Moves a scroll offset by delta and keeps it inside [0, max].
18
+ export const scrollBy = (offset, delta, max) => Math.max(0, Math.min(Math.max(0, max), offset + delta));
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { type RunItem } from '../data.ts';
3
+ import type { Screen } from '../screens.ts';
4
+ export declare const POLL_LOG_MS = 500;
5
+ export interface LogText {
6
+ lines: string[];
7
+ partial: string;
8
+ }
9
+ export declare function appendChunk(text: LogText, chunk: string): LogText;
10
+ export declare function wrapLines(lines: string[], width: number): string[];
11
+ export interface Scroll {
12
+ top: number | null;
13
+ }
14
+ export declare const bottomTop: (total: number, size: number) => number;
15
+ export declare function scrollBy(s: Scroll, total: number, size: number, delta: number): Scroll;
16
+ export declare function visibleRows(rows: string[], size: number, s: Scroll): string[];
17
+ export declare function logFor(run: RunItem, step: string | undefined): string | undefined;
18
+ export declare function lineColour(line: string): string | undefined;
19
+ export declare function useTail(path: string | null): LogText & {
20
+ missing: boolean;
21
+ };
22
+ export declare function LogView(props: {
23
+ params: Record<string, unknown>;
24
+ }): React.ReactElement;
25
+ export declare const logTitle: (p: Record<string, unknown>) => string[];
26
+ export declare const logScreen: Screen;
@@ -0,0 +1,266 @@
1
+ // The live log screen: steps on the left, the selected step's log on the right,
2
+ // following the end of the file like tail -f.
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { join } from 'node:path';
8
+ import { useHints, useKeys, useScreen } from "../context.js";
9
+ import { useData } from "../data.js";
10
+ import { Lines, Panel, seg } from "../widgets.js";
11
+ import { theme } from "../theme.js";
12
+ import { cancelSpec, isLive, settledSteps, stepDuration, stepMark } from "./runs.js";
13
+ const h = React.createElement;
14
+ export const POLL_LOG_MS = 500;
15
+ const MAX_LINES = 5000;
16
+ const FIRST_READ_BYTES = 512 * 1024;
17
+ const clean = (s) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '').replace(/\t/g, ' ').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
18
+ // Adds a freshly read chunk: whole lines are kept, the unfinished one waits.
19
+ export function appendChunk(text, chunk) {
20
+ const parts = (text.partial + clean(chunk)).split('\n');
21
+ const partial = parts.pop() ?? '';
22
+ const lines = text.lines.concat(parts);
23
+ return { lines: lines.length > MAX_LINES ? lines.slice(lines.length - MAX_LINES) : lines, partial };
24
+ }
25
+ // Wraps every line to rows of at most `width` characters, breaking at spaces.
26
+ // Only a word longer than the width is cut mid-word. Blank lines stay.
27
+ export function wrapLines(lines, width) {
28
+ const w = Math.max(8, width);
29
+ const out = [];
30
+ for (const line of lines) {
31
+ if (line.length <= w) {
32
+ out.push(line);
33
+ continue;
34
+ }
35
+ let row = '';
36
+ let broke = false;
37
+ for (const tok of line.match(/\S+|\s+/g) ?? []) {
38
+ if (/^\s/.test(tok)) {
39
+ // Spaces that would start a wrapped row, or overflow this one, are dropped.
40
+ if (row.length + tok.length <= w && !(broke && row === ''))
41
+ row += tok;
42
+ continue;
43
+ }
44
+ if (row.length + tok.length <= w) {
45
+ row += tok;
46
+ continue;
47
+ }
48
+ if (row.trim() !== '') {
49
+ out.push(row.trimEnd());
50
+ broke = true;
51
+ }
52
+ row = tok;
53
+ while (row.length > w) {
54
+ out.push(row.slice(0, w));
55
+ broke = true;
56
+ row = row.slice(w);
57
+ }
58
+ }
59
+ out.push(row.trimEnd());
60
+ }
61
+ return out;
62
+ }
63
+ export const bottomTop = (total, size) => Math.max(0, total - size);
64
+ export function scrollBy(s, total, size, delta) {
65
+ const cur = s.top ?? bottomTop(total, size);
66
+ return { top: Math.max(0, Math.min(bottomTop(total, size), cur + delta)) };
67
+ }
68
+ export function visibleRows(rows, size, s) {
69
+ const top = s.top === null ? bottomTop(rows.length, size) : Math.min(s.top, bottomTop(rows.length, size));
70
+ return rows.slice(top, top + size);
71
+ }
72
+ // Log name for a step: its recorded file, else a file that names the step.
73
+ export function logFor(run, step) {
74
+ const steps = run.record?.steps ?? [];
75
+ if (step) {
76
+ const rec = [...steps].reverse().find((s) => s.step === step || s.label === step);
77
+ if (rec?.log && run.logs.includes(rec.log))
78
+ return rec.log;
79
+ const byName = [...run.logs].reverse().find((l) => l.includes(step.replace(/^vs-/, '')));
80
+ if (byName)
81
+ return byName;
82
+ }
83
+ const running = [...steps].reverse().find((s) => !s.endedAt)?.log;
84
+ if (running && run.logs.includes(running))
85
+ return running;
86
+ return run.logs[run.logs.length - 1];
87
+ }
88
+ export function lineColour(line) {
89
+ if (line.startsWith('$ '))
90
+ return theme.accent;
91
+ if (/^\s*(✓|PASS)/.test(line))
92
+ return theme.green;
93
+ if (/^\s*(✗|FAIL|Error)/.test(line))
94
+ return theme.red;
95
+ return undefined;
96
+ }
97
+ // ---- tailing a file --------------------------------------------------------
98
+ // Reads the file once, then only the bytes added since, every 500ms.
99
+ export function useTail(path) {
100
+ const [text, setText] = useState({ lines: [], partial: '', missing: false });
101
+ useEffect(() => {
102
+ setText({ lines: [], partial: '', missing: false });
103
+ if (!path)
104
+ return;
105
+ let offset = -1;
106
+ let acc = { lines: [], partial: '' };
107
+ let decoder = new StringDecoder('utf8');
108
+ const tick = () => {
109
+ let size;
110
+ try {
111
+ size = statSync(path).size;
112
+ }
113
+ catch {
114
+ setText((t) => (t.missing ? t : { ...t, missing: true }));
115
+ return;
116
+ }
117
+ if (offset < 0) {
118
+ offset = Math.max(0, size - FIRST_READ_BYTES);
119
+ if (offset > 0)
120
+ acc = { lines: [], partial: '' };
121
+ }
122
+ else if (size < offset) {
123
+ offset = 0;
124
+ acc = { lines: [], partial: '' };
125
+ decoder = new StringDecoder('utf8');
126
+ }
127
+ else if (size === offset)
128
+ return;
129
+ try {
130
+ const fd = openSync(path, 'r');
131
+ try {
132
+ const buf = Buffer.alloc(size - offset);
133
+ const n = readSync(fd, buf, 0, buf.length, offset);
134
+ offset += n;
135
+ acc = appendChunk(acc, decoder.write(buf.subarray(0, n)));
136
+ }
137
+ finally {
138
+ closeSync(fd);
139
+ }
140
+ setText({ ...acc, missing: false });
141
+ }
142
+ catch {
143
+ // try again next tick
144
+ }
145
+ };
146
+ tick();
147
+ const t = setInterval(tick, POLL_LOG_MS);
148
+ return () => clearInterval(t);
149
+ }, [path]);
150
+ return text;
151
+ }
152
+ // ---- the screen ------------------------------------------------------------
153
+ const LEFT = 24;
154
+ export function LogView(props) {
155
+ const ctx = useScreen();
156
+ const { state } = useData();
157
+ const [now, setNow] = useState(Date.now());
158
+ useEffect(() => {
159
+ const t = setInterval(() => setNow(Date.now()), 1000);
160
+ return () => clearInterval(t);
161
+ }, []);
162
+ const card = String(props.params.card ?? '');
163
+ const dir = typeof props.params.dir === 'string' ? props.params.dir : null;
164
+ const run = state.runs.find((r) => (dir ? r.dir === dir : r.card === card));
165
+ const live = isLive(run, state);
166
+ const record = run?.record;
167
+ const [picked, setPicked] = useState(null);
168
+ const [scroll, setScroll] = useState({ top: null });
169
+ const wanted = typeof props.params.step === 'string' ? props.params.step : undefined;
170
+ const autoFile = run ? logFor(run, picked ? undefined : wanted) : undefined;
171
+ // Until the user picks a log, a live run moves on to the newest one.
172
+ const followNewest = !picked && live && !!run && run.logs.length > 0;
173
+ const file = picked ?? (followNewest ? run.logs[run.logs.length - 1] : autoFile);
174
+ const path = run && file ? join(run.dir, file) : null;
175
+ const text = useTail(path);
176
+ const rightWidth = Math.max(30, ctx.columns - LEFT - 1);
177
+ const panelHeight = Math.max(6, ctx.rows - 4);
178
+ const size = panelHeight - 2;
179
+ const all = text.partial ? [...text.lines, text.partial] : text.lines;
180
+ const rows = useMemo(() => wrapLines(all, rightWidth - 4), [text.lines, text.partial, rightWidth]);
181
+ const following = scroll.top === null;
182
+ const hints = [
183
+ { key: 'f', label: following ? 'follow off' : 'follow on' },
184
+ { key: '↑↓', label: 'scroll' },
185
+ { key: '1-9', label: 'step' },
186
+ { key: 'c', label: 'cancel card' },
187
+ { key: 'esc', label: 'back' },
188
+ ];
189
+ useHints(hints);
190
+ useEffect(() => {
191
+ ctx.setHeaderRight(following ? 'following' : 'scrolled', following ? theme.accent : theme.dim);
192
+ return () => ctx.setHeaderRight(null);
193
+ }, [following]);
194
+ const sizeRef = useRef(size);
195
+ sizeRef.current = size;
196
+ useKeys((input, key) => {
197
+ const total = rows.length;
198
+ if (input === 'f')
199
+ setScroll(following ? { top: bottomTop(total, size) } : { top: null });
200
+ else if (key.upArrow)
201
+ setScroll((s) => scrollBy(s, total, size, -1));
202
+ else if (key.downArrow)
203
+ setScroll((s) => scrollBy(s, total, size, 1));
204
+ else if (key.pageUp)
205
+ setScroll((s) => scrollBy(s, total, size, -(size - 1)));
206
+ else if (key.pageDown)
207
+ setScroll((s) => scrollBy(s, total, size, size - 1));
208
+ else if (/^[1-9]$/.test(input) && run) {
209
+ const name = run.logs[Number(input) - 1];
210
+ if (name) {
211
+ setPicked(name);
212
+ setScroll({ top: null });
213
+ }
214
+ }
215
+ else if (input === 'c') {
216
+ if (!live)
217
+ ctx.toast(`${card} is not running, so there is nothing to cancel.`, 'info');
218
+ else
219
+ void ctx.actions.run(cancelSpec(card, state.agents?.current?.step));
220
+ }
221
+ });
222
+ if (!run) {
223
+ return h(Panel, { title: card || 'Log' }, h(Text, { color: theme.dim }, state.loaded ? `No saved run for ${card}.` : 'Loading...'));
224
+ }
225
+ // Left: steps, round, log files.
226
+ const steps = settledSteps(run, state);
227
+ const left = steps.map((s) => {
228
+ const m = stepMark(s);
229
+ const on = !!file && s.log === file;
230
+ return [seg(`${m.glyph} `, m.colour), seg(s.label.padEnd(8), on ? theme.accent : undefined, { bold: on }), seg(stepDuration(s, now), theme.dim)];
231
+ });
232
+ if (!steps.length)
233
+ left.push([seg('No steps yet', theme.dim)]);
234
+ const workflow = state.workflows.find((w) => w.name === record?.workflow);
235
+ const cur = state.agents?.current;
236
+ const round = live && cur?.round !== undefined ? cur.round + 1 : Math.max(1, steps.filter((s) => s.step === steps[0]?.step).length);
237
+ left.push([], [seg(`round ${round}${workflow?.maxRounds ? ` of ${workflow.maxRounds}` : ''}`, theme.dim)], []);
238
+ run.logs.forEach((l) => {
239
+ const on = l === file;
240
+ left.push([seg(on ? '▶ ' : ' ', theme.accent), seg(l, on ? theme.accent : theme.dim, { bold: on })]);
241
+ });
242
+ const view = visibleRows(rows, size, scroll);
243
+ const body = text.missing
244
+ ? [h(Text, { key: 'm', color: theme.dim }, 'This log has not been written yet.')]
245
+ : view.map((r, i) => h(Text, { key: i, wrap: 'truncate-end', color: lineColour(r) }, r || ' '));
246
+ if (!text.missing && live && following && view.length < size)
247
+ body.push(h(Text, { key: 'cur', color: theme.accent }, '▍'));
248
+ return h(Box, { flexDirection: 'row' }, h(Panel, { title: 'Steps', width: LEFT, height: panelHeight }, h(Lines, { lines: left })), h(Box, { width: 1 }), h(Panel, { title: file ?? 'Log', focused: true, width: rightWidth, height: panelHeight, subtitle: rows.length > size && !following ? `${(scroll.top ?? 0) + 1}/${rows.length}` : undefined }, ...body));
249
+ }
250
+ // The step's name for the header: "QA log".
251
+ export const logTitle = (p) => {
252
+ const label = typeof p.stepLabel === 'string' ? p.stepLabel : typeof p.step === 'string' ? p.step.replace(/^vs-/, '') : '';
253
+ return [String(p.card ?? 'Log'), label ? `${label} log` : 'Log'];
254
+ };
255
+ export const logScreen = {
256
+ id: 'agents.log',
257
+ title: logTitle,
258
+ component: LogView,
259
+ hints: () => [
260
+ { key: 'f', label: 'follow on/off' },
261
+ { key: '↑↓', label: 'scroll' },
262
+ { key: '1-9', label: 'step' },
263
+ { key: 'c', label: 'cancel card' },
264
+ { key: 'esc', label: 'back' },
265
+ ],
266
+ };
@@ -0,0 +1,24 @@
1
+ import { type MemoryStatus } from '../data.ts';
2
+ import { type Line } from '../widgets.ts';
3
+ import type { Screen } from '../screens.ts';
4
+ type Home = MemoryStatus['homes'][number];
5
+ export interface VaultProblems {
6
+ unindexed: string[];
7
+ brokenIndex: string[];
8
+ brokenLinks: {
9
+ note: string;
10
+ link: string;
11
+ }[];
12
+ }
13
+ export declare const kindLabel: (k: string) => string;
14
+ export declare function recallText(recall: string): {
15
+ text: string;
16
+ colour: string;
17
+ };
18
+ export declare const toggleArgs: (mode: "on" | "off", home: Home) => string[];
19
+ export declare const tryArgs: (prompt: string) => string[];
20
+ export declare function parseProblems(output: string): VaultProblems | null;
21
+ export declare function problemLines(p: VaultProblems): Line[];
22
+ export declare function vaultLines(m: MemoryStatus, now?: Date): Line[];
23
+ export declare const memoryScreen: Screen;
24
+ export {};