Skip to content

Commit d0029ec

Browse files
grypezclaude
andcommitted
feat(kernel-tui): add TUI with sessions view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent de519e0 commit d0029ec

26 files changed

Lines changed: 1892 additions & 16 deletions

packages/kernel-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"@metamask/eslint-config": "^15.0.0",
6767
"@metamask/eslint-config-nodejs": "^15.0.0",
6868
"@metamask/eslint-config-typescript": "^15.0.0",
69+
"@ocap/kernel-tui": "workspace:^",
6970
"@ocap/repo-tools": "workspace:^",
7071
"@ts-bridge/cli": "^0.6.3",
7172
"@ts-bridge/shims": "^0.1.1",

packages/kernel-cli/src/app.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import '@metamask/kernel-shims/endoify-node';
22
import { Logger } from '@metamask/logger';
33
import type { LogEntry } from '@metamask/logger';
4+
import { spawn } from 'node:child_process';
5+
import { access } from 'node:fs/promises';
6+
import { createRequire } from 'node:module';
47
import path from 'node:path';
58
import yargs from 'yargs';
69
import { hideBin } from 'yargs/helpers';
@@ -22,7 +25,7 @@ import {
2225
stopRelay,
2326
} from './commands/relay.ts';
2427
import { getServer } from './commands/serve.ts';
25-
import { buildSessionCommands } from './commands/session.ts';
28+
import { buildSessionCommands, resolveSessionUrl } from './commands/session.ts';
2629
import { watchDir } from './commands/watch.ts';
2730
import { defaultConfig } from './config.ts';
2831
import type { Config } from './config.ts';
@@ -44,6 +47,25 @@ function consoleTransport(entry: LogEntry): void {
4447

4548
const logger = new Logger({ tags: ['cli'], transports: [consoleTransport] });
4649

50+
/**
51+
* Resolve the built ocap-tui binary from the @ocap/kernel-tui workspace package.
52+
* Returns undefined if the package cannot be resolved or its dist output hasn't
53+
* been built yet (run `yarn build` from the repo root to fix the latter).
54+
*
55+
* @returns The absolute path to dist/app.mjs, or undefined if not found.
56+
*/
57+
async function findTuiBinPath(): Promise<string | undefined> {
58+
try {
59+
const resolve = createRequire(import.meta.url);
60+
const pkgPath = resolve.resolve('@ocap/kernel-tui/package.json');
61+
const binPath = path.join(path.dirname(pkgPath), 'dist', 'app.mjs');
62+
await access(binPath);
63+
return binPath;
64+
} catch {
65+
return undefined;
66+
}
67+
}
68+
4769
const yargsInstance = yargs(hideBin(process.argv))
4870
.scriptName('ocap')
4971
.usage('$0 <command> [options]')
@@ -435,5 +457,70 @@ const yargsInstance = yargs(hideBin(process.argv))
435457
() => {
436458
// Handled by subcommands.
437459
},
460+
)
461+
.command(
462+
'modal <sid>',
463+
'Open an interactive TUI for a session',
464+
(_yargs) =>
465+
_yargs.positional('sid', {
466+
type: 'string',
467+
demandOption: true,
468+
describe: 'Session ID (from ocap session create)',
469+
}),
470+
async (args) => {
471+
const socketPath = getSocketPath();
472+
const binPath = await findTuiBinPath();
473+
if (binPath === undefined) {
474+
process.stderr.write(
475+
'Error: kernel-tui binary not found.\n' +
476+
'Run `yarn build` from the repository root to build it first.\n',
477+
);
478+
process.exitCode = 1;
479+
return;
480+
}
481+
await ensureDaemon(socketPath);
482+
const ocapUrl = await resolveSessionUrl(socketPath, String(args.sid));
483+
if (ocapUrl === undefined) {
484+
return;
485+
}
486+
await new Promise<void>((resolve, reject) => {
487+
const child = spawn(process.execPath, [binPath, 'modal', ocapUrl], {
488+
stdio: 'inherit',
489+
});
490+
child.on('close', (code) => {
491+
process.exitCode = code ?? 0;
492+
resolve();
493+
});
494+
child.on('error', reject);
495+
});
496+
},
497+
)
498+
.command(
499+
'tui',
500+
'Open the full interactive kernel TUI',
501+
(_yargs) => _yargs,
502+
async () => {
503+
const socketPath = getSocketPath();
504+
const binPath = await findTuiBinPath();
505+
if (binPath === undefined) {
506+
process.stderr.write(
507+
'Error: kernel-tui binary not found.\n' +
508+
'Run `yarn build` from the repository root to build it first.\n',
509+
);
510+
process.exitCode = 1;
511+
return;
512+
}
513+
await ensureDaemon(socketPath);
514+
await new Promise<void>((resolve, reject) => {
515+
const child = spawn(process.execPath, [binPath, 'tui'], {
516+
stdio: 'inherit',
517+
});
518+
child.on('close', (code) => {
519+
process.exitCode = code ?? 0;
520+
resolve();
521+
});
522+
child.on('error', reject);
523+
});
524+
},
438525
);
439526
await yargsInstance.help('help').parse();

packages/kernel-tui/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
[Unreleased]: https://github.com/MetaMask/ocap-kernel/

packages/kernel-tui/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# `@ocap/kernel-tui`
2+
3+
Interactive terminal UI for the OCAP kernel
4+
5+
## Installation
6+
7+
`yarn add @ocap/kernel-tui`
8+
9+
or
10+
11+
`npm install @ocap/kernel-tui`
12+
13+
## Contributing
14+
15+
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme).

packages/kernel-tui/package.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"name": "@ocap/kernel-tui",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Interactive terminal UI for the OCAP kernel",
6+
"homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/kernel-tui#readme",
7+
"bugs": {
8+
"url": "https://github.com/MetaMask/ocap-kernel/issues"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "https://github.com/MetaMask/ocap-kernel.git"
13+
},
14+
"type": "module",
15+
"bin": {
16+
"ocap-tui": "./dist/app.mjs"
17+
},
18+
"exports": {
19+
"./package.json": "./package.json"
20+
},
21+
"files": [
22+
"dist/"
23+
],
24+
"scripts": {
25+
"build": "ts-bridge --project tsconfig.build.json --no-references --clean && chmod +x dist/app.mjs",
26+
"build:docs": "typedoc",
27+
"changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-tui",
28+
"clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs",
29+
"lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies",
30+
"lint:dependencies": "depcheck --quiet",
31+
"lint:eslint": "eslint . --cache",
32+
"lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies",
33+
"lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error",
34+
"publish:preview": "yarn npm publish --tag preview",
35+
"test": "vitest run --config vitest.config.ts",
36+
"test:clean": "yarn test --no-cache --coverage.clean",
37+
"test:dev": "yarn test --mode development",
38+
"test:verbose": "yarn test --reporter verbose",
39+
"test:watch": "vitest --config vitest.config.ts",
40+
"test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent"
41+
},
42+
"devDependencies": {
43+
"@arethetypeswrong/cli": "^0.17.4",
44+
"@metamask/auto-changelog": "^5.3.0",
45+
"@metamask/eslint-config": "^15.0.0",
46+
"@metamask/eslint-config-nodejs": "^15.0.0",
47+
"@metamask/eslint-config-typescript": "^15.0.0",
48+
"@ocap/repo-tools": "workspace:^",
49+
"@ts-bridge/cli": "^0.6.3",
50+
"@ts-bridge/shims": "^0.1.1",
51+
"@types/node": "^22.13.1",
52+
"@types/react": "^18.3.18",
53+
"@types/yargs": "^17.0.33",
54+
"@typescript-eslint/eslint-plugin": "^8.29.0",
55+
"@typescript-eslint/parser": "^8.29.0",
56+
"@typescript-eslint/utils": "^8.29.0",
57+
"@vitest/eslint-plugin": "^1.6.14",
58+
"depcheck": "^1.4.7",
59+
"eslint": "^9.23.0",
60+
"eslint-config-prettier": "^10.1.1",
61+
"eslint-import-resolver-typescript": "^4.3.1",
62+
"eslint-plugin-import-x": "^4.10.0",
63+
"eslint-plugin-jsdoc": "^50.6.9",
64+
"eslint-plugin-n": "^17.17.0",
65+
"eslint-plugin-prettier": "^5.2.6",
66+
"eslint-plugin-promise": "^7.2.1",
67+
"prettier": "^3.5.3",
68+
"rimraf": "^6.0.1",
69+
"turbo": "^2.9.1",
70+
"typedoc": "^0.28.1",
71+
"typescript": "~5.8.2",
72+
"typescript-eslint": "^8.29.0",
73+
"vite": "^8.0.6",
74+
"vitest": "^4.1.3"
75+
},
76+
"engines": {
77+
"node": ">=22"
78+
},
79+
"dependencies": {
80+
"@metamask/kernel-node-runtime": "workspace:^",
81+
"@metamask/kernel-shims": "workspace:^",
82+
"@metamask/kernel-utils": "workspace:^",
83+
"@metamask/streams": "workspace:^",
84+
"glob": "^11.0.0",
85+
"ink": "^5.2.1",
86+
"ink-select-input": "^6.0.0",
87+
"ink-spinner": "^5.0.0",
88+
"ink-text-input": "^6.0.0",
89+
"react": "^18.3.1",
90+
"yargs": "^17.7.2"
91+
}
92+
}

packages/kernel-tui/src/app.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import '@metamask/kernel-shims/endoify-node';
2+
import { getSocketPath } from '@metamask/kernel-node-runtime/daemon';
3+
import yargs from 'yargs';
4+
import { hideBin } from 'yargs/helpers';
5+
6+
import { makeDaemonKernelApi } from './hooks/use-kernel.ts';
7+
import { runModal } from './modal.tsx';
8+
import { startTui } from './start-tui.ts';
9+
10+
const yargsInstance = yargs(hideBin(process.argv))
11+
.scriptName('ocap-tui')
12+
.usage('$0 <command> [options]')
13+
.demandCommand(1)
14+
.strict()
15+
.command(
16+
'tui',
17+
'Open the full interactive kernel TUI (connected to the daemon)',
18+
(_yargs) =>
19+
_yargs.option('socket-path', {
20+
type: 'string',
21+
describe: 'Daemon socket path (defaults to standard path)',
22+
default: getSocketPath(),
23+
}),
24+
async (args) => {
25+
const kernelApi = makeDaemonKernelApi(args['socket-path']);
26+
await startTui({ cwd: process.cwd(), kernelApi });
27+
},
28+
)
29+
.command(
30+
'modal <ocap-url>',
31+
'Open an interactive TUI for a modal channel',
32+
(_yargs) =>
33+
_yargs.positional('ocap-url', {
34+
type: 'string',
35+
demandOption: true,
36+
describe: 'OCAP URL of the channel (from `ocap session create`)',
37+
}),
38+
async (args) => {
39+
await runModal(args['ocap-url']);
40+
},
41+
);
42+
43+
await yargsInstance.help('help').parse();
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { glob } from 'glob';
2+
import { Box, Text } from 'ink';
3+
import SelectInput from 'ink-select-input';
4+
import Spinner from 'ink-spinner';
5+
import { readFile } from 'node:fs/promises';
6+
import path from 'node:path';
7+
import React, { useEffect, useState } from 'react';
8+
9+
import type { KernelApi } from '../types.ts';
10+
11+
type FileBrowserProps = {
12+
cwd: string;
13+
kernelApi: KernelApi;
14+
onLog: (message: string) => void;
15+
};
16+
17+
type FileItem = {
18+
label: string;
19+
value: string;
20+
};
21+
22+
/**
23+
* File browser for discovering and launching .bundle and subcluster.json files.
24+
*
25+
* @param props - Component props.
26+
* @param props.cwd - Current working directory to scan.
27+
* @param props.kernelApi - Kernel API for launching subclusters.
28+
* @param props.onLog - Callback to add a log message.
29+
* @returns The FileBrowser component.
30+
*/
31+
export function FileBrowser({
32+
cwd,
33+
kernelApi,
34+
onLog,
35+
}: FileBrowserProps): React.ReactElement {
36+
const [files, setFiles] = useState<FileItem[]>([]);
37+
const [loading, setLoading] = useState(false);
38+
const [result, setResult] = useState<string | null>(null);
39+
40+
useEffect(() => {
41+
Promise.all([
42+
glob('**/*.bundle', { cwd, maxDepth: 3 }),
43+
glob('**/subcluster.json', { cwd, maxDepth: 3 }),
44+
])
45+
.then(([bundleFiles, jsonFiles]) => {
46+
const items = [...bundleFiles, ...jsonFiles].map((file) => ({
47+
label: file,
48+
value: path.resolve(cwd, file),
49+
}));
50+
setFiles(items);
51+
return undefined;
52+
})
53+
.catch(() => undefined);
54+
}, [cwd]);
55+
56+
const handleSelect = (item: FileItem): void => {
57+
setLoading(true);
58+
setResult(null);
59+
const filePath = item.value;
60+
61+
(async () => {
62+
const content = await readFile(filePath, 'utf-8');
63+
let config: Record<string, unknown>;
64+
65+
if (filePath.endsWith('.json')) {
66+
config = JSON.parse(content) as Record<string, unknown>;
67+
} else {
68+
config = {
69+
bootstrap: 'main',
70+
vats: { main: { bundleSpec: `file://${filePath}` } },
71+
};
72+
}
73+
74+
const launchResult = await kernelApi.launchSubcluster(config);
75+
const logMessage = `Launched ${item.label} → kref: ${launchResult.bootstrapRootKref}`;
76+
setResult(logMessage);
77+
onLog(logMessage);
78+
})()
79+
.catch((error: Error) => {
80+
const logMessage = `Error launching ${item.label}: ${error.message}`;
81+
setResult(logMessage);
82+
onLog(logMessage);
83+
})
84+
.finally(() => setLoading(false));
85+
};
86+
87+
return (
88+
<Box flexDirection="column" paddingX={1}>
89+
<Text bold>File Browser</Text>
90+
<Text dimColor>Select a .bundle or subcluster.json to launch</Text>
91+
{files.length === 0 ? (
92+
<Text color="yellow">
93+
No .bundle or subcluster.json files found in {cwd}
94+
</Text>
95+
) : (
96+
<SelectInput items={files} onSelect={handleSelect} />
97+
)}
98+
{loading && (
99+
<Text>
100+
<Spinner type="dots" /> Launching...
101+
</Text>
102+
)}
103+
{result && <Text color="green">{result}</Text>}
104+
</Box>
105+
);
106+
}

0 commit comments

Comments
 (0)