@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.
- package/LICENSE +202 -0
- package/README.md +134 -2
- package/dist/checks.d.ts +9 -0
- package/dist/checks.js +51 -0
- package/dist/commands/args.d.ts +13 -0
- package/dist/commands/args.js +22 -0
- package/dist/commands/config.d.ts +1 -0
- package/dist/commands/config.js +181 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.js +52 -0
- package/dist/commands/help.d.ts +3 -0
- package/dist/commands/help.js +59 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +135 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +60 -0
- package/dist/commands/tools.d.ts +9 -0
- package/dist/commands/tools.js +111 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +79 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +29 -0
- package/dist/dotted.d.ts +9 -0
- package/dist/dotted.js +86 -0
- package/dist/loader.d.ts +6 -0
- package/dist/loader.js +51 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +93 -0
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +26 -0
- package/dist/tool.d.ts +51 -0
- package/dist/tool.js +4 -0
- package/dist/tui/actions.d.ts +17 -0
- package/dist/tui/actions.js +99 -0
- package/dist/tui/app.d.ts +6 -0
- package/dist/tui/app.js +246 -0
- package/dist/tui/context.d.ts +62 -0
- package/dist/tui/context.js +48 -0
- package/dist/tui/data.d.ts +196 -0
- package/dist/tui/data.js +302 -0
- package/dist/tui/format.d.ts +11 -0
- package/dist/tui/format.js +54 -0
- package/dist/tui/home.d.ts +51 -0
- package/dist/tui/home.js +336 -0
- package/dist/tui/keys.d.ts +2 -0
- package/dist/tui/keys.js +107 -0
- package/dist/tui/nav.d.ts +24 -0
- package/dist/tui/nav.js +32 -0
- package/dist/tui/palette.d.ts +32 -0
- package/dist/tui/palette.js +269 -0
- package/dist/tui/run.d.ts +1 -0
- package/dist/tui/run.js +20 -0
- package/dist/tui/screens/agents.d.ts +6 -0
- package/dist/tui/screens/agents.js +44 -0
- package/dist/tui/screens/awake.d.ts +23 -0
- package/dist/tui/screens/awake.js +209 -0
- package/dist/tui/screens/checks.d.ts +11 -0
- package/dist/tui/screens/checks.js +106 -0
- package/dist/tui/screens/homes.d.ts +14 -0
- package/dist/tui/screens/homes.js +99 -0
- package/dist/tui/screens/list-window.d.ts +6 -0
- package/dist/tui/screens/list-window.js +18 -0
- package/dist/tui/screens/log.d.ts +26 -0
- package/dist/tui/screens/log.js +266 -0
- package/dist/tui/screens/memory.d.ts +24 -0
- package/dist/tui/screens/memory.js +174 -0
- package/dist/tui/screens/project-form.d.ts +14 -0
- package/dist/tui/screens/project-form.js +137 -0
- package/dist/tui/screens/projects.d.ts +17 -0
- package/dist/tui/screens/projects.js +109 -0
- package/dist/tui/screens/runs.d.ts +43 -0
- package/dist/tui/screens/runs.js +420 -0
- package/dist/tui/screens/settings.d.ts +17 -0
- package/dist/tui/screens/settings.js +153 -0
- package/dist/tui/screens/workflow-form.d.ts +58 -0
- package/dist/tui/screens/workflow-form.js +375 -0
- package/dist/tui/screens/workflows.d.ts +41 -0
- package/dist/tui/screens/workflows.js +174 -0
- package/dist/tui/screens.d.ts +30 -0
- package/dist/tui/screens.js +172 -0
- package/dist/tui/theme.d.ts +14 -0
- package/dist/tui/theme.js +18 -0
- package/dist/tui/widgets.d.ts +122 -0
- package/dist/tui/widgets.js +264 -0
- package/dist/ui.d.ts +5 -0
- package/dist/ui.js +82 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +17 -0
- package/package.json +59 -4
package/dist/loader.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
+
// Inside the monorepo, tools load straight from source. Installed copies
|
|
14
|
+
// live under <configDir>/tools, one npm prefix shared by every tool.
|
|
15
|
+
function sourcePath(entry) {
|
|
16
|
+
return fileURLToPath(new URL(`../../../tools/${entry.name}/src/index.ts`, import.meta.url));
|
|
17
|
+
}
|
|
18
|
+
function installedPath(entry, configDir) {
|
|
19
|
+
return join(configDir, 'tools', 'node_modules', entry.pkg, 'dist', 'index.js');
|
|
20
|
+
}
|
|
21
|
+
export function locate(entry, configDir) {
|
|
22
|
+
for (const p of [sourcePath(entry), installedPath(entry, configDir)])
|
|
23
|
+
if (existsSync(p))
|
|
24
|
+
return p;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
// npm runs with its output captured so it stays off the screen; on failure
|
|
28
|
+
// the last lines of it are shown.
|
|
29
|
+
export function npm(args) {
|
|
30
|
+
const r = spawnSync('npm', args, { stdio: 'pipe', encoding: 'utf8' });
|
|
31
|
+
if (r.status === 0)
|
|
32
|
+
return true;
|
|
33
|
+
const out = `${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? r.error.message : ''}`.trim();
|
|
34
|
+
if (out)
|
|
35
|
+
console.error(out.split('\n').slice(-20).join('\n'));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function fetchTool(entry, configDir) {
|
|
39
|
+
if (locate(entry, configDir))
|
|
40
|
+
return true;
|
|
41
|
+
return npm(['install', '--silent', '--no-audit', '--no-fund', '--prefix', join(configDir, 'tools'), entry.pkg]) && locate(entry, configDir) !== null;
|
|
42
|
+
}
|
|
43
|
+
// Tools added elsewhere (or before an update) may not be downloaded on this
|
|
44
|
+
// install yet, so fetch on first use rather than failing.
|
|
45
|
+
export async function loadTool(entry, configDir) {
|
|
46
|
+
const path = locate(entry, configDir) ?? (fetchTool(entry, configDir) ? locate(entry, configDir) : null);
|
|
47
|
+
if (!path)
|
|
48
|
+
throw new Error(`could not download ${entry.pkg}. Check your connection and run vsf setup again.`);
|
|
49
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
|
|
50
|
+
return mod.default;
|
|
51
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readConfig } from "./config.js";
|
|
3
|
+
import { config } from "./commands/config.js";
|
|
4
|
+
import { doctor } from "./commands/doctor.js";
|
|
5
|
+
import { help, mainHelp, usages } from "./commands/help.js";
|
|
6
|
+
import { setup, setupCommand } from "./commands/setup.js";
|
|
7
|
+
import { status } from "./commands/status.js";
|
|
8
|
+
import { update } from "./commands/update.js";
|
|
9
|
+
import { cliVersion } from "./version.js";
|
|
10
|
+
import { wantsHelp } from "./commands/args.js";
|
|
11
|
+
import { addTool, listTools, removeTool, runToolCommand } from "./commands/tools.js";
|
|
12
|
+
import { findTool } from "./registry.js";
|
|
13
|
+
import { Cancelled } from "./ui.js";
|
|
14
|
+
// Ink and React load only here, so plain commands never pay for them.
|
|
15
|
+
async function openDashboard() {
|
|
16
|
+
return (await import("./tui/run.js")).runDashboard();
|
|
17
|
+
}
|
|
18
|
+
async function main(argv) {
|
|
19
|
+
const [cmd, ...rest] = argv;
|
|
20
|
+
// Umbrella commands answer --help themselves; tools handle their own.
|
|
21
|
+
if (cmd && Object.hasOwn(usages, cmd) && wantsHelp(rest)) {
|
|
22
|
+
console.log(usages[cmd]);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
switch (cmd) {
|
|
26
|
+
case undefined:
|
|
27
|
+
case '--defaults':
|
|
28
|
+
if (cmd === '--defaults')
|
|
29
|
+
return setup(true);
|
|
30
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
31
|
+
if (readConfig().tools.length === 0)
|
|
32
|
+
return setup(false);
|
|
33
|
+
return openDashboard();
|
|
34
|
+
}
|
|
35
|
+
console.log(mainHelp());
|
|
36
|
+
return 0;
|
|
37
|
+
case 'ui':
|
|
38
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
39
|
+
console.error('vsf ui needs a terminal. Use vsf status for plain output.');
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
return openDashboard();
|
|
43
|
+
case 'setup':
|
|
44
|
+
return setupCommand(rest);
|
|
45
|
+
case 'status':
|
|
46
|
+
return status(rest);
|
|
47
|
+
case 'doctor':
|
|
48
|
+
return doctor(rest);
|
|
49
|
+
case 'config':
|
|
50
|
+
return config(rest);
|
|
51
|
+
case 'tools':
|
|
52
|
+
return listTools();
|
|
53
|
+
case 'add':
|
|
54
|
+
return addTool(rest[0]);
|
|
55
|
+
case 'remove':
|
|
56
|
+
return removeTool(rest[0]);
|
|
57
|
+
case 'update':
|
|
58
|
+
return update();
|
|
59
|
+
case '-v':
|
|
60
|
+
case '--version':
|
|
61
|
+
console.log(cliVersion());
|
|
62
|
+
return 0;
|
|
63
|
+
case '-h':
|
|
64
|
+
case '--help':
|
|
65
|
+
console.log(mainHelp());
|
|
66
|
+
return 0;
|
|
67
|
+
case 'help':
|
|
68
|
+
return help(rest);
|
|
69
|
+
}
|
|
70
|
+
const tool = findTool(cmd);
|
|
71
|
+
if (tool)
|
|
72
|
+
return runToolCommand(tool, rest);
|
|
73
|
+
console.error(`unknown command: ${cmd}. Run vsf help.`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const e = err;
|
|
81
|
+
if (err instanceof Cancelled)
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
else if (typeof e.code === 'string' && e.code.startsWith('ERR_PARSE_ARGS')) {
|
|
84
|
+
console.error(`vsf: ${e.message.replace(/\.$/, '')}. Run vsf help.`);
|
|
85
|
+
process.exitCode = 2;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.error(`vsf: ${e.message}`);
|
|
89
|
+
if (process.env.VSF_DEBUG === '1' && e.stack)
|
|
90
|
+
console.error(e.stack);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Tools the CLI knows about. Each ships as its own npm package and is
|
|
2
|
+
// installed on `vsf add <name>`. Status flips to 'available' once the
|
|
3
|
+
// package is published.
|
|
4
|
+
export const registry = [
|
|
5
|
+
{
|
|
6
|
+
name: 'awake',
|
|
7
|
+
summary: 'Keep the Mac running on battery with the lid closed, with guardrails',
|
|
8
|
+
pkg: '@voidlabs.gg/voidshift-awake',
|
|
9
|
+
status: 'available',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'agents',
|
|
13
|
+
summary: 'Agents for Claude Code and Codex that plan, build and QA Linear cards',
|
|
14
|
+
pkg: '@voidlabs.gg/voidshift-agents',
|
|
15
|
+
status: 'available',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'memory',
|
|
19
|
+
summary: 'Markdown memory vault recalled into Claude Code and Codex',
|
|
20
|
+
pkg: '@voidlabs.gg/voidshift-memory',
|
|
21
|
+
status: 'available',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
export function findTool(name) {
|
|
25
|
+
return registry.find((t) => t.name === name);
|
|
26
|
+
}
|
package/dist/tool.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type CheckStatus = 'ok' | 'warn' | 'fail';
|
|
2
|
+
export interface Check {
|
|
3
|
+
name: string;
|
|
4
|
+
status: CheckStatus;
|
|
5
|
+
detail: string;
|
|
6
|
+
fix?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Choice<T extends string> {
|
|
9
|
+
value: T;
|
|
10
|
+
label: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Ui {
|
|
14
|
+
info(line: string): void;
|
|
15
|
+
warn(line: string): void;
|
|
16
|
+
text(message: string, o?: {
|
|
17
|
+
initial?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
validate?: (v: string) => string | undefined;
|
|
20
|
+
}): Promise<string>;
|
|
21
|
+
secret(message: string, validate?: (v: string) => string | undefined): Promise<string>;
|
|
22
|
+
confirm(message: string, initial?: boolean): Promise<boolean>;
|
|
23
|
+
select<T extends string>(message: string, choices: Choice<T>[], initial?: T): Promise<T>;
|
|
24
|
+
multiselect<T extends string>(message: string, choices: Choice<T>[], initial?: T[]): Promise<T[]>;
|
|
25
|
+
search<T extends string>(message: string, choices: Choice<T>[], placeholder?: string, initial?: T): Promise<T>;
|
|
26
|
+
path(message: string, o?: {
|
|
27
|
+
initial?: string;
|
|
28
|
+
validate?: (v: string) => string | undefined;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
}
|
|
31
|
+
export interface ToolContext {
|
|
32
|
+
defaults: boolean;
|
|
33
|
+
home: string;
|
|
34
|
+
configDir: string;
|
|
35
|
+
selfCommand: string[];
|
|
36
|
+
log: (line: string) => void;
|
|
37
|
+
ui: Ui;
|
|
38
|
+
}
|
|
39
|
+
export type ToolCommand = (args: string[], ctx: ToolContext) => Promise<number>;
|
|
40
|
+
export interface Tool {
|
|
41
|
+
name: string;
|
|
42
|
+
summary: string;
|
|
43
|
+
install(ctx: ToolContext): Promise<void>;
|
|
44
|
+
uninstall(ctx: ToolContext): Promise<void>;
|
|
45
|
+
checks(ctx: ToolContext): Promise<Check[]>;
|
|
46
|
+
commands?: Record<string, ToolCommand>;
|
|
47
|
+
config?: {
|
|
48
|
+
read(configDir: string): Record<string, unknown>;
|
|
49
|
+
write(configDir: string, value: Record<string, unknown>): string[];
|
|
50
|
+
};
|
|
51
|
+
}
|
package/dist/tool.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ActionResult {
|
|
2
|
+
code: number;
|
|
3
|
+
output: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function selfCommand(): string[];
|
|
6
|
+
export declare function commandLine(args: string[]): string;
|
|
7
|
+
export declare function runAction(args: string[], opts?: {
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}): Promise<ActionResult>;
|
|
11
|
+
export declare function spawnDetached(args: string[], logFile: string): {
|
|
12
|
+
pid: number | undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare function runInTerminal(args: string[]): number;
|
|
15
|
+
export declare function needsTerminal(args: string[]): boolean;
|
|
16
|
+
export declare const firstLine: (text: string) => string;
|
|
17
|
+
export declare const lastLine: (text: string) => string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Everything the dashboard does, it does by running a real vsf command as a
|
|
2
|
+
// child process (the same selfCommand the CLI hands to launchd). The dashboard
|
|
3
|
+
// can therefore never do something a command cannot. Reads may happen
|
|
4
|
+
// in-process (see data.ts); writes and anything that can block or exit the
|
|
5
|
+
// process (agents run, tick, cancel, stop, restart, start) come through here.
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { closeSync, mkdirSync, openSync, realpathSync, writeSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
// How to start this CLI again: node plus the real path of the entry file.
|
|
10
|
+
export function selfCommand() {
|
|
11
|
+
return [process.execPath, realpathSync(process.argv[1] ?? '')];
|
|
12
|
+
}
|
|
13
|
+
// "vsf agents cancel ENG-12", for the footer and for confirm dialogs.
|
|
14
|
+
export function commandLine(args) {
|
|
15
|
+
const quote = (a) => (/^[\w@%+=:,./~-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
|
|
16
|
+
return ['vsf', ...args.map(quote)].join(' ');
|
|
17
|
+
}
|
|
18
|
+
// Runs `vsf <args>` with stdin ignored and stdout and stderr captured.
|
|
19
|
+
export function runAction(args, opts = {}) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const [cmd, ...pre] = selfCommand();
|
|
22
|
+
let output = '';
|
|
23
|
+
let child;
|
|
24
|
+
try {
|
|
25
|
+
child = spawn(cmd, [...pre, ...args], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...opts.env } });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
resolve({ code: 1, output: err.message });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const timer = opts.timeoutMs
|
|
32
|
+
? setTimeout(() => {
|
|
33
|
+
output += '\nTimed out.';
|
|
34
|
+
child.kill('SIGTERM');
|
|
35
|
+
}, opts.timeoutMs)
|
|
36
|
+
: null;
|
|
37
|
+
child.stdout.on('data', (d) => (output += d.toString()));
|
|
38
|
+
child.stderr.on('data', (d) => (output += d.toString()));
|
|
39
|
+
child.on('error', (err) => {
|
|
40
|
+
if (timer)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve({ code: 1, output: output + err.message });
|
|
43
|
+
});
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
if (timer)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve({ code: code ?? 1, output: output.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim() });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Starts a long run that outlives the keypress: output is appended to
|
|
52
|
+
// logFile and the dashboard goes on, following the run from run.json.
|
|
53
|
+
export function spawnDetached(args, logFile) {
|
|
54
|
+
mkdirSync(dirname(logFile), { recursive: true });
|
|
55
|
+
const fd = openSync(logFile, 'a');
|
|
56
|
+
try {
|
|
57
|
+
writeSync(fd, `\n--- ${new Date().toISOString()} ${commandLine(args)}\n`);
|
|
58
|
+
const [cmd, ...pre] = selfCommand();
|
|
59
|
+
const child = spawn(cmd, [...pre, ...args], { detached: true, stdio: ['ignore', fd, fd] });
|
|
60
|
+
child.on('error', () => { });
|
|
61
|
+
child.unref();
|
|
62
|
+
return { pid: child.pid };
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Runs a command that needs the real terminal (prompts, an editor, a long
|
|
69
|
+
// foreground run) with stdio inherited. Call it only while Ink has released
|
|
70
|
+
// the terminal (useApp().suspendTerminal).
|
|
71
|
+
export function runInTerminal(args) {
|
|
72
|
+
const [cmd, ...pre] = selfCommand();
|
|
73
|
+
return spawnSync(cmd, [...pre, ...args], { stdio: 'inherit' }).status ?? 1;
|
|
74
|
+
}
|
|
75
|
+
// Commands that prompt, open an editor or run in the foreground. The
|
|
76
|
+
// dashboard hands them the terminal instead of capturing their output.
|
|
77
|
+
export function needsTerminal(args) {
|
|
78
|
+
const [a, b, c] = args;
|
|
79
|
+
if (a === 'setup' || a === 'add' || a === 'remove')
|
|
80
|
+
return true;
|
|
81
|
+
if (a === 'config' && b === 'edit')
|
|
82
|
+
return true;
|
|
83
|
+
if (a === 'agents') {
|
|
84
|
+
if (b === 'run' || b === 'key')
|
|
85
|
+
return true;
|
|
86
|
+
if (b === 'logs' && args.some((x) => x === '-f' || x === '--follow'))
|
|
87
|
+
return true;
|
|
88
|
+
if ((b === 'workflow' || b === 'project') && (c === 'add' || c === 'edit') && !args.includes('--yes'))
|
|
89
|
+
return true;
|
|
90
|
+
if ((b === 'workflow' || b === 'project') && c === 'rm' && !args.includes('--yes') && !args.includes('-y'))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (a === 'awake' && b === 'logs' && args.some((x) => x === '-f' || x === '--follow'))
|
|
94
|
+
return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
// First and last non-empty lines, for toasts.
|
|
98
|
+
export const firstLine = (text) => text.split('\n').find((l) => l.trim())?.trim() ?? '';
|
|
99
|
+
export const lastLine = (text) => [...text.split('\n')].reverse().find((l) => l.trim())?.trim() ?? '';
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The shell: a stack of screens, the header, the footer of key hints, a toast
|
|
2
|
+
// line, the confirm dialog, the key help and the command palette.
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
4
|
+
import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
|
|
5
|
+
import { commandLine, firstLine, lastLine, runAction, runInTerminal, spawnDetached } from "./actions.js";
|
|
6
|
+
import { usages } from "../commands/help.js";
|
|
7
|
+
import { ScreenCtx, } from "./context.js";
|
|
8
|
+
import { DataProvider, updateNotice, useData } from "./data.js";
|
|
9
|
+
import { useKeyBatching } from "./keys.js";
|
|
10
|
+
import { breadcrumb, HOME, initialStack, navReducer } from "./nav.js";
|
|
11
|
+
import { Palette, parseUsages } from "./palette.js";
|
|
12
|
+
import { crumbsOf, hintsOf, screens } from "./screens.js";
|
|
13
|
+
import { theme } from "./theme.js";
|
|
14
|
+
import { Confirm, KeyHints, Panel } from "./widgets.js";
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
const toastColour = (k) => (k === 'ok' ? theme.green : k === 'error' ? theme.red : theme.accent);
|
|
17
|
+
const toastMark = (k) => (k === 'ok' ? '✓' : k === 'error' ? '✗' : '·');
|
|
18
|
+
export function App(props) {
|
|
19
|
+
return h(DataProvider, { poller: props.poller }, h(Shell));
|
|
20
|
+
}
|
|
21
|
+
export function Shell() {
|
|
22
|
+
const { exit, suspendTerminal } = useApp();
|
|
23
|
+
const data = useData();
|
|
24
|
+
const { columns, rows } = useWindowSize();
|
|
25
|
+
const [stack, dispatch] = useReducer(navReducer, undefined, initialStack);
|
|
26
|
+
const [overlay, setOverlay] = useState(null);
|
|
27
|
+
const [toast, setToast] = useState(null);
|
|
28
|
+
const [meta, setMeta] = useState({});
|
|
29
|
+
const [, bump] = useState(0);
|
|
30
|
+
const captures = useRef({});
|
|
31
|
+
const guards = useRef({});
|
|
32
|
+
const toastId = useRef(0);
|
|
33
|
+
const umbrella = useMemo(() => parseUsages(usages), []);
|
|
34
|
+
const top = stack.length - 1;
|
|
35
|
+
const route = stack[top];
|
|
36
|
+
const showToast = useCallback((text, kind = 'info') => {
|
|
37
|
+
const id = ++toastId.current;
|
|
38
|
+
setToast({ id, text, kind });
|
|
39
|
+
if (kind !== 'info')
|
|
40
|
+
setTimeout(() => setToast((t) => (t && t.id === id ? null : t)), 5000);
|
|
41
|
+
}, []);
|
|
42
|
+
const confirm = useCallback((options, command) => new Promise((resolve) => {
|
|
43
|
+
setOverlay({
|
|
44
|
+
type: 'confirm',
|
|
45
|
+
options,
|
|
46
|
+
command,
|
|
47
|
+
resolve: (ok) => {
|
|
48
|
+
setOverlay(null);
|
|
49
|
+
resolve(ok);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}), []);
|
|
53
|
+
const terminal = useCallback(async (args) => {
|
|
54
|
+
let code = 1;
|
|
55
|
+
await suspendTerminal(async () => {
|
|
56
|
+
process.stdout.write(`\n$ ${commandLine(args)}\n\n`);
|
|
57
|
+
code = runInTerminal(args);
|
|
58
|
+
process.stdout.write(`\n${code === 0 ? 'Done.' : `Exited with code ${code}.`} Press Enter to return to vsf. `);
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
const onData = () => {
|
|
61
|
+
process.stdin.off('data', onData);
|
|
62
|
+
process.stdin.pause();
|
|
63
|
+
resolve();
|
|
64
|
+
};
|
|
65
|
+
process.stdin.on('data', onData);
|
|
66
|
+
process.stdin.resume();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
data.refresh();
|
|
70
|
+
return code;
|
|
71
|
+
}, [suspendTerminal, data.refresh]);
|
|
72
|
+
const actions = useMemo(() => ({
|
|
73
|
+
confirm: (options) => confirm(options),
|
|
74
|
+
terminal,
|
|
75
|
+
detach(args, logFile, text) {
|
|
76
|
+
try {
|
|
77
|
+
spawnDetached(args, logFile);
|
|
78
|
+
showToast(text ?? `Started ${commandLine(args)}`, 'ok');
|
|
79
|
+
setTimeout(data.refresh, 1500);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
showToast(err.message, 'error');
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async run(spec) {
|
|
86
|
+
if (!spec.noConfirm) {
|
|
87
|
+
const ok = await confirm({ title: spec.title, body: spec.body, confirmLabel: spec.confirmLabel, cancelLabel: spec.cancelLabel }, spec.args);
|
|
88
|
+
if (!ok)
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
showToast(`Running ${commandLine(spec.args)}`, 'info');
|
|
92
|
+
const result = await runAction(spec.args, { timeoutMs: spec.timeoutMs });
|
|
93
|
+
data.refresh();
|
|
94
|
+
if (result.code === 0) {
|
|
95
|
+
showToast(spec.done ?? (firstLine(result.output) || 'Done'), 'ok');
|
|
96
|
+
spec.after?.(result);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
showToast(lastLine(result.output) || `${commandLine(spec.args)} failed (exit code ${result.code})`, 'error');
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
},
|
|
103
|
+
}), [confirm, terminal, showToast, data.refresh]);
|
|
104
|
+
const patchMeta = useCallback((index, patch) => setMeta((m) => ({ ...m, [index]: { ...m[index], ...patch } })), []);
|
|
105
|
+
// One context per stacked screen, so a hidden screen cannot change the footer.
|
|
106
|
+
const contexts = useMemo(() => stack.map((r, index) => {
|
|
107
|
+
const isTop = index === top;
|
|
108
|
+
return {
|
|
109
|
+
id: r.id,
|
|
110
|
+
params: r.params,
|
|
111
|
+
nav: {
|
|
112
|
+
push: (id, params) => dispatch({ type: 'push', id, params }),
|
|
113
|
+
pop: () => dispatch({ type: 'pop' }),
|
|
114
|
+
replace: (id, params) => dispatch({ type: 'replace', id, params }),
|
|
115
|
+
depth: stack.length,
|
|
116
|
+
},
|
|
117
|
+
actions,
|
|
118
|
+
toast: showToast,
|
|
119
|
+
setHints: (hints) => patchMeta(index, { hints }),
|
|
120
|
+
setCommand: (args) => patchMeta(index, { command: args }),
|
|
121
|
+
setHeaderRight: (text, colour) => patchMeta(index, { right: text ? { text, colour } : null }),
|
|
122
|
+
capture: () => {
|
|
123
|
+
captures.current[index] = (captures.current[index] ?? 0) + 1;
|
|
124
|
+
bump((n) => n + 1);
|
|
125
|
+
return () => {
|
|
126
|
+
captures.current[index] = Math.max(0, (captures.current[index] ?? 1) - 1);
|
|
127
|
+
bump((n) => n + 1);
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
capturing: isTop && (captures.current[index] ?? 0) > 0,
|
|
131
|
+
setBackGuard: (fn) => {
|
|
132
|
+
guards.current[index] = fn;
|
|
133
|
+
},
|
|
134
|
+
modalOpen: overlay !== null || !isTop,
|
|
135
|
+
columns,
|
|
136
|
+
rows,
|
|
137
|
+
};
|
|
138
|
+
}), [stack, top, overlay, actions, showToast, patchMeta, columns, rows, captures.current[top]]);
|
|
139
|
+
// Hints and the equivalent command belong to the entry they were set on.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setMeta((m) => Object.fromEntries(Object.entries(m).filter(([i]) => Number(i) <= top)));
|
|
142
|
+
}, [top]);
|
|
143
|
+
const topCapturing = (captures.current[top] ?? 0) > 0;
|
|
144
|
+
// Several keys in one chunk are handled one by one, except into a text field.
|
|
145
|
+
useKeyBatching(() => topCapturing || overlay?.type === 'palette');
|
|
146
|
+
useInput((input, key) => {
|
|
147
|
+
if (overlay || topCapturing)
|
|
148
|
+
return;
|
|
149
|
+
if (key.escape) {
|
|
150
|
+
if (guards.current[top]?.())
|
|
151
|
+
return;
|
|
152
|
+
dispatch({ type: 'pop' });
|
|
153
|
+
}
|
|
154
|
+
else if (input === 'q')
|
|
155
|
+
exit();
|
|
156
|
+
else if (input === ':')
|
|
157
|
+
setOverlay({ type: 'palette' });
|
|
158
|
+
else if (input === '?')
|
|
159
|
+
setOverlay({ type: 'help' });
|
|
160
|
+
}, { isActive: true });
|
|
161
|
+
// ---- render ----
|
|
162
|
+
const crumbs = breadcrumb(stack, (r) => crumbsOf(r.id, r.params));
|
|
163
|
+
const notice = updateNotice(data.state.update);
|
|
164
|
+
const right = meta[top]?.right;
|
|
165
|
+
const header = h(Box, { paddingX: 1, flexShrink: 0 }, h(Box, { flexGrow: 1 }, route.id === HOME
|
|
166
|
+
? h(Text, null, h(Text, { bold: true, color: theme.bold }, 'VoidShift'), h(Text, { color: theme.dim }, ` ${data.state.version}`))
|
|
167
|
+
: h(Text, null, ...crumbs.flatMap((c, i) => [
|
|
168
|
+
i > 0 ? h(Text, { key: `s${i}`, color: theme.dim }, ' › ') : null,
|
|
169
|
+
h(Text, { key: `c${i}`, bold: i === 0, color: i === 0 ? theme.bold : undefined }, c),
|
|
170
|
+
]))), right
|
|
171
|
+
? h(Text, { color: right.colour ?? theme.dim }, right.text)
|
|
172
|
+
: notice
|
|
173
|
+
? h(Text, { color: theme.yellow }, route.id === HOME ? `${notice} is out · u updates` : `${notice} is out`)
|
|
174
|
+
: null);
|
|
175
|
+
const body = h(Box, { flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }, ...stack.map((r, index) => {
|
|
176
|
+
const screen = screens[r.id];
|
|
177
|
+
const visible = index === top && overlay === null;
|
|
178
|
+
return h(Box, { key: `${index}:${r.id}`, display: visible ? 'flex' : 'none', flexDirection: 'column' }, h(ScreenCtx.Provider, { value: contexts[index] }, screen ? h(screen.component, { params: r.params }) : h(Text, { color: theme.red }, `Unknown screen ${r.id}`)));
|
|
179
|
+
}), overlay ? renderOverlay(overlay, { umbrella, setOverlay, data, terminal, hints: footerHints(route, meta[top]) }) : null);
|
|
180
|
+
const toastLine = h(Box, { paddingX: 1, flexShrink: 0, height: 1 }, toast ? h(Text, { color: toastColour(toast.kind), wrap: 'truncate-end' }, `${toastMark(toast.kind)} ${toast.text}`) : h(Text, null, ' '));
|
|
181
|
+
let hints = footerHints(route, meta[top]);
|
|
182
|
+
let command = meta[top]?.command ?? null;
|
|
183
|
+
if (overlay?.type === 'confirm') {
|
|
184
|
+
hints = [
|
|
185
|
+
{ key: '←→', label: 'choose' },
|
|
186
|
+
{ key: 'enter', label: 'confirm' },
|
|
187
|
+
{ key: 'esc', label: 'cancel' },
|
|
188
|
+
];
|
|
189
|
+
command = overlay.command ?? null;
|
|
190
|
+
}
|
|
191
|
+
else if (overlay?.type === 'palette') {
|
|
192
|
+
hints = [
|
|
193
|
+
{ key: 'enter', label: 'run' },
|
|
194
|
+
{ key: 'tab', label: 'complete' },
|
|
195
|
+
{ key: '↑↓', label: 'pick' },
|
|
196
|
+
{ key: 'esc', label: 'close' },
|
|
197
|
+
];
|
|
198
|
+
command = null;
|
|
199
|
+
}
|
|
200
|
+
else if (overlay?.type === 'help') {
|
|
201
|
+
hints = [{ key: 'esc', label: 'close' }];
|
|
202
|
+
command = null;
|
|
203
|
+
}
|
|
204
|
+
const footer = h(Box, { flexShrink: 0 }, h(KeyHints, { hints, width: columns, right: command ? `= ${commandLine(command)}` : undefined }));
|
|
205
|
+
return h(Box, { flexDirection: 'column', height: rows, width: columns }, header, body, toastLine, footer);
|
|
206
|
+
}
|
|
207
|
+
function footerHints(route, meta) {
|
|
208
|
+
return meta?.hints ?? hintsOf(route.id, route.params);
|
|
209
|
+
}
|
|
210
|
+
function renderOverlay(overlay, env) {
|
|
211
|
+
if (overlay.type === 'confirm') {
|
|
212
|
+
return h(Confirm, {
|
|
213
|
+
title: overlay.options.title,
|
|
214
|
+
body: overlay.options.body,
|
|
215
|
+
confirmLabel: overlay.options.confirmLabel,
|
|
216
|
+
cancelLabel: overlay.options.cancelLabel,
|
|
217
|
+
onConfirm: () => overlay.resolve(true),
|
|
218
|
+
onCancel: () => overlay.resolve(false),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (overlay.type === 'palette') {
|
|
222
|
+
return h(Palette, {
|
|
223
|
+
umbrella: env.umbrella,
|
|
224
|
+
onClose: () => env.setOverlay(null),
|
|
225
|
+
onRun: async (args) => {
|
|
226
|
+
const r = await runAction(args);
|
|
227
|
+
env.data.refresh();
|
|
228
|
+
return r;
|
|
229
|
+
},
|
|
230
|
+
onTerminal: (args) => void env.terminal(args),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return h(HelpOverlay, { hints: env.hints, onClose: () => env.setOverlay(null) });
|
|
234
|
+
}
|
|
235
|
+
const GLOBAL_KEYS = [
|
|
236
|
+
{ key: 'esc', label: 'go back' },
|
|
237
|
+
{ key: ':', label: 'command palette: every vsf command' },
|
|
238
|
+
{ key: '?', label: 'this list' },
|
|
239
|
+
{ key: 'q', label: 'quit' },
|
|
240
|
+
{ key: 'ctrl+c', label: 'quit from anywhere' },
|
|
241
|
+
];
|
|
242
|
+
function HelpOverlay(props) {
|
|
243
|
+
useInput(() => props.onClose());
|
|
244
|
+
const line = (hint, i, tag = '') => h(Text, { key: `${tag}${i}` }, h(Text, { color: theme.accent }, hint.key.padEnd(8)), h(Text, null, hint.label));
|
|
245
|
+
return h(Panel, { title: 'Keys', focused: true }, h(Text, { color: theme.dim }, 'On this screen'), ...props.hints.map((hint, i) => line(hint, i, 's')), h(Text, null, ' '), h(Text, { color: theme.dim }, 'Everywhere'), ...GLOBAL_KEYS.map((hint, i) => line(hint, i, 'g')));
|
|
246
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Key } from 'ink';
|
|
2
|
+
import type { ActionResult } from './actions.ts';
|
|
3
|
+
import type { Params } from './nav.ts';
|
|
4
|
+
export interface Hint {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
export type ToastKind = 'ok' | 'error' | 'info';
|
|
9
|
+
export interface ActionSpec {
|
|
10
|
+
args: string[];
|
|
11
|
+
title: string;
|
|
12
|
+
body?: string;
|
|
13
|
+
confirmLabel?: string;
|
|
14
|
+
cancelLabel?: string;
|
|
15
|
+
noConfirm?: boolean;
|
|
16
|
+
done?: string;
|
|
17
|
+
after?: (result: ActionResult) => void;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ConfirmOptions {
|
|
21
|
+
title: string;
|
|
22
|
+
body?: string;
|
|
23
|
+
confirmLabel?: string;
|
|
24
|
+
cancelLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Actions {
|
|
27
|
+
run(spec: ActionSpec): Promise<ActionResult | null>;
|
|
28
|
+
confirm(options: ConfirmOptions): Promise<boolean>;
|
|
29
|
+
detach(args: string[], logFile: string, toast?: string): void;
|
|
30
|
+
terminal(args: string[]): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
export interface Nav {
|
|
33
|
+
push(id: string, params?: Params): void;
|
|
34
|
+
pop(): void;
|
|
35
|
+
replace(id: string, params?: Params): void;
|
|
36
|
+
depth: number;
|
|
37
|
+
}
|
|
38
|
+
export interface ScreenContext {
|
|
39
|
+
id: string;
|
|
40
|
+
params: Params;
|
|
41
|
+
nav: Nav;
|
|
42
|
+
actions: Actions;
|
|
43
|
+
toast(text: string, kind?: ToastKind): void;
|
|
44
|
+
setHints(hints: Hint[] | null): void;
|
|
45
|
+
setCommand(args: string[] | null): void;
|
|
46
|
+
setHeaderRight(text: string | null, colour?: string): void;
|
|
47
|
+
capture(): () => void;
|
|
48
|
+
capturing: boolean;
|
|
49
|
+
setBackGuard(fn: (() => boolean) | null): void;
|
|
50
|
+
modalOpen: boolean;
|
|
51
|
+
columns: number;
|
|
52
|
+
rows: number;
|
|
53
|
+
}
|
|
54
|
+
export declare const ScreenCtx: import("react").Context<ScreenContext | null>;
|
|
55
|
+
export declare function useScreen(): ScreenContext;
|
|
56
|
+
export declare function useKeys(handler: (input: string, key: Key) => void, opts?: {
|
|
57
|
+
whileCapturing?: boolean;
|
|
58
|
+
}): void;
|
|
59
|
+
export declare function useHints(hints: Hint[] | null): void;
|
|
60
|
+
export declare function useEquivalent(args: string[] | null): void;
|
|
61
|
+
export declare function useCapture(active: boolean): void;
|
|
62
|
+
export declare function useBackGuard(fn: (() => boolean) | null): void;
|