Skip to content

Commit 57d9c9f

Browse files
grypezclaude
andcommitted
feat(kernel-tui): add session detail view, history, and keybinds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d0029ec commit 57d9c9f

13 files changed

Lines changed: 1739 additions & 113 deletions

File tree

packages/kernel-cli/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `ocap session` subcommands: `list`, `get`, `requests`, and `decide`
13+
- Add `ocap tui` and `ocap modal` commands to launch the terminal UI
14+
1015
## [0.1.0]
1116

1217
### Added
Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
import { createConnection } from 'node:net';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { vi, describe, it, expect, afterEach } from 'vitest';
5+
6+
import type { RpcSocketServerHandle } from './rpc-socket-server.ts';
7+
import type { Session, SessionRegistry } from './session-registry.ts';
8+
9+
// Mock @metamask/kernel-rpc-methods and @metamask/ocap-kernel/rpc so no real
10+
// kernel initialisation occurs. The factory must be self-contained (no outer
11+
// references) because vi.mock factories are hoisted before other imports.
12+
vi.mock('@metamask/kernel-rpc-methods', () => {
13+
class MockRpcService {
14+
assertHasMethod = vi.fn();
15+
16+
execute = vi.fn().mockResolvedValue(null);
17+
}
18+
return { RpcService: MockRpcService };
19+
});
20+
21+
vi.mock('@metamask/ocap-kernel/rpc', () => ({
22+
rpcHandlers: {},
23+
}));
24+
25+
// ---------------------------------------------------------------------------
26+
// Helpers
27+
// ---------------------------------------------------------------------------
28+
29+
type JsonRpcResponse = {
30+
jsonrpc: string;
31+
id: number;
32+
result?: unknown;
33+
error?: { code: number; message: string };
34+
};
35+
36+
async function sendRequest(
37+
socketPath: string,
38+
method: string,
39+
params: Record<string, unknown> = {},
40+
): Promise<JsonRpcResponse> {
41+
return new Promise((resolve, reject) => {
42+
const socket = createConnection(socketPath, () => {
43+
const request = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
44+
socket.write(`${request}\n`);
45+
});
46+
47+
let buffer = '';
48+
socket.on('data', (chunk: Buffer) => {
49+
buffer += chunk.toString();
50+
});
51+
socket.on('end', () => {
52+
try {
53+
resolve(JSON.parse(buffer.trim()) as JsonRpcResponse);
54+
} catch (parseError) {
55+
reject(parseError);
56+
}
57+
});
58+
socket.on('error', reject);
59+
});
60+
}
61+
62+
function makeTestSession(overrides: Partial<Session> = {}): Session {
63+
return {
64+
sessionId: 'alice',
65+
ocapUrl: 'ocap://test-url',
66+
startedAt: '2026-01-01T00:00:00.000Z',
67+
listPending: vi.fn().mockReturnValue([]),
68+
listHistory: vi.fn().mockReturnValue([]),
69+
decide: vi.fn(),
70+
queueRequest: vi.fn().mockReturnValue('req-0'),
71+
authorizeRequest: vi.fn().mockResolvedValue({
72+
token: 'req-0',
73+
verdict: 'accept' as const,
74+
feedback: '',
75+
}),
76+
subscribe: vi.fn(),
77+
...overrides,
78+
};
79+
}
80+
81+
function makeTestRegistry(
82+
initial: Session[] = [],
83+
): SessionRegistry & { _sessions: Map<string, Session> } {
84+
const sessions = new Map<string, Session>(
85+
initial.map((session) => [session.sessionId, session]),
86+
);
87+
let nameIndex = 0;
88+
const names = ['alice', 'bob', 'carol'];
89+
90+
return {
91+
_sessions: sessions,
92+
async createSession(
93+
options: { name?: string; cwd?: string } = {},
94+
): Promise<Session> {
95+
const sessionId =
96+
options.name ?? names[nameIndex] ?? `session-${nameIndex}`;
97+
nameIndex += 1;
98+
const session = makeTestSession({
99+
sessionId,
100+
ocapUrl: `ocap://${sessionId}`,
101+
startedAt: '2026-01-01T00:00:00.000Z',
102+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
103+
});
104+
sessions.set(sessionId, session);
105+
return session;
106+
},
107+
getSession(sessionId: string): Session | undefined {
108+
return sessions.get(sessionId);
109+
},
110+
listSessions(): Session[] {
111+
return Array.from(sessions.values());
112+
},
113+
getChannelByUrl(_url: string) {
114+
return undefined;
115+
},
116+
};
117+
}
118+
119+
function makeSocketPath(): string {
120+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
121+
return join(tmpdir(), `rpc-server-test-${suffix}.sock`);
122+
}
123+
124+
// ---------------------------------------------------------------------------
125+
// Tests
126+
// ---------------------------------------------------------------------------
127+
128+
describe('startRpcSocketServer — session.* methods', () => {
129+
let handle: RpcSocketServerHandle | undefined;
130+
131+
afterEach(async () => {
132+
if (handle) {
133+
const toClose = handle;
134+
handle = undefined;
135+
await toClose.close();
136+
}
137+
vi.clearAllMocks();
138+
});
139+
140+
it('session.create response includes sessionId, ocapUrl, startedAt', async () => {
141+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
142+
const socketPath = makeSocketPath();
143+
const registry = makeTestRegistry();
144+
145+
handle = await startRpcSocketServer({
146+
socketPath,
147+
kernel: {} as never,
148+
kernelDatabase: { executeQuery: vi.fn() } as never,
149+
channelFactory: {} as never,
150+
sessionRegistry: registry,
151+
});
152+
153+
const response = await sendRequest(socketPath, 'session.create', {});
154+
155+
expect(response.result).toStrictEqual({
156+
sessionId: 'alice',
157+
ocapUrl: 'ocap://alice',
158+
startedAt: '2026-01-01T00:00:00.000Z',
159+
});
160+
});
161+
162+
it('session.create with cwd param includes cwd in response', async () => {
163+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
164+
const socketPath = makeSocketPath();
165+
const registry = makeTestRegistry();
166+
167+
handle = await startRpcSocketServer({
168+
socketPath,
169+
kernel: {} as never,
170+
kernelDatabase: { executeQuery: vi.fn() } as never,
171+
channelFactory: {} as never,
172+
sessionRegistry: registry,
173+
});
174+
175+
const response = await sendRequest(socketPath, 'session.create', {
176+
cwd: '/home/user',
177+
});
178+
179+
expect(response.result).toStrictEqual({
180+
sessionId: 'alice',
181+
ocapUrl: 'ocap://alice',
182+
cwd: '/home/user',
183+
startedAt: '2026-01-01T00:00:00.000Z',
184+
});
185+
});
186+
187+
it('session.create without cwd param omits cwd from response', async () => {
188+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
189+
const socketPath = makeSocketPath();
190+
const registry = makeTestRegistry();
191+
192+
handle = await startRpcSocketServer({
193+
socketPath,
194+
kernel: {} as never,
195+
kernelDatabase: { executeQuery: vi.fn() } as never,
196+
channelFactory: {} as never,
197+
sessionRegistry: registry,
198+
});
199+
200+
const response = await sendRequest(socketPath, 'session.create', {});
201+
202+
expect(response.result).not.toHaveProperty('cwd');
203+
});
204+
205+
it('session.list returns sessions with sessionId, ocapUrl, startedAt', async () => {
206+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
207+
const socketPath = makeSocketPath();
208+
const existing = makeTestSession({
209+
sessionId: 'alice',
210+
ocapUrl: 'ocap://alice',
211+
startedAt: '2026-01-01T00:00:00.000Z',
212+
});
213+
const registry = makeTestRegistry([existing]);
214+
215+
handle = await startRpcSocketServer({
216+
socketPath,
217+
kernel: {} as never,
218+
kernelDatabase: { executeQuery: vi.fn() } as never,
219+
channelFactory: {} as never,
220+
sessionRegistry: registry,
221+
});
222+
223+
const response = await sendRequest(socketPath, 'session.list', {});
224+
225+
expect(response.result).toStrictEqual([
226+
{
227+
sessionId: 'alice',
228+
ocapUrl: 'ocap://alice',
229+
startedAt: '2026-01-01T00:00:00.000Z',
230+
},
231+
]);
232+
});
233+
234+
it('session.get returns session with sessionId, ocapUrl, startedAt', async () => {
235+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
236+
const socketPath = makeSocketPath();
237+
const existing = makeTestSession({
238+
sessionId: 'alice',
239+
ocapUrl: 'ocap://alice',
240+
startedAt: '2026-01-01T00:00:00.000Z',
241+
});
242+
const registry = makeTestRegistry([existing]);
243+
244+
handle = await startRpcSocketServer({
245+
socketPath,
246+
kernel: {} as never,
247+
kernelDatabase: { executeQuery: vi.fn() } as never,
248+
channelFactory: {} as never,
249+
sessionRegistry: registry,
250+
});
251+
252+
const response = await sendRequest(socketPath, 'session.get', {
253+
sessionId: 'alice',
254+
});
255+
256+
expect(response.result).toStrictEqual({
257+
sessionId: 'alice',
258+
ocapUrl: 'ocap://alice',
259+
startedAt: '2026-01-01T00:00:00.000Z',
260+
});
261+
});
262+
263+
it('session.get with unknown sessionId returns error code -32602', async () => {
264+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
265+
const socketPath = makeSocketPath();
266+
const registry = makeTestRegistry();
267+
268+
handle = await startRpcSocketServer({
269+
socketPath,
270+
kernel: {} as never,
271+
kernelDatabase: { executeQuery: vi.fn() } as never,
272+
channelFactory: {} as never,
273+
sessionRegistry: registry,
274+
});
275+
276+
const response = await sendRequest(socketPath, 'session.get', {
277+
sessionId: 'nonexistent',
278+
});
279+
280+
expect(response.error).toStrictEqual({
281+
code: -32602,
282+
message: 'Session not found: nonexistent',
283+
});
284+
});
285+
286+
it('session.history returns listHistory() result for an existing session', async () => {
287+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
288+
const socketPath = makeSocketPath();
289+
const historyEntries = [
290+
{
291+
token: 'req-0',
292+
description: 'Test request',
293+
reason: 'Testing',
294+
guard: { body: '#{}', slots: [] as string[] },
295+
queuedAt: '2026-01-01T00:01:00.000Z',
296+
status: 'accepted' as const,
297+
decidedAt: '2026-01-01T00:01:05.000Z',
298+
},
299+
];
300+
const existing = makeTestSession({
301+
sessionId: 'alice',
302+
ocapUrl: 'ocap://alice',
303+
startedAt: '2026-01-01T00:00:00.000Z',
304+
listHistory: vi.fn().mockReturnValue(historyEntries),
305+
});
306+
const registry = makeTestRegistry([existing]);
307+
308+
handle = await startRpcSocketServer({
309+
socketPath,
310+
kernel: {} as never,
311+
kernelDatabase: { executeQuery: vi.fn() } as never,
312+
channelFactory: {} as never,
313+
sessionRegistry: registry,
314+
});
315+
316+
const response = await sendRequest(socketPath, 'session.history', {
317+
sessionId: 'alice',
318+
});
319+
320+
expect(response.result).toStrictEqual(historyEntries);
321+
});
322+
323+
it('session.history with unknown sessionId returns error code -32602', async () => {
324+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
325+
const socketPath = makeSocketPath();
326+
const registry = makeTestRegistry();
327+
328+
handle = await startRpcSocketServer({
329+
socketPath,
330+
kernel: {} as never,
331+
kernelDatabase: { executeQuery: vi.fn() } as never,
332+
channelFactory: {} as never,
333+
sessionRegistry: registry,
334+
});
335+
336+
const response = await sendRequest(socketPath, 'session.history', {
337+
sessionId: 'unknown-session',
338+
});
339+
340+
expect(response.error).toStrictEqual({
341+
code: -32602,
342+
message: 'Session not found: unknown-session',
343+
});
344+
});
345+
346+
it('session.authorize returns the decision from authorizeRequest()', async () => {
347+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
348+
const socketPath = makeSocketPath();
349+
const decision = {
350+
token: 'req-0',
351+
verdict: 'accept' as const,
352+
feedback: 'Looks good',
353+
};
354+
const existing = makeTestSession({
355+
sessionId: 'alice',
356+
ocapUrl: 'ocap://alice',
357+
startedAt: '2026-01-01T00:00:00.000Z',
358+
authorizeRequest: vi.fn().mockResolvedValue(decision),
359+
});
360+
const registry = makeTestRegistry([existing]);
361+
362+
handle = await startRpcSocketServer({
363+
socketPath,
364+
kernel: {} as never,
365+
kernelDatabase: { executeQuery: vi.fn() } as never,
366+
channelFactory: {} as never,
367+
sessionRegistry: registry,
368+
});
369+
370+
const response = await sendRequest(socketPath, 'session.authorize', {
371+
sessionId: 'alice',
372+
description: 'Allow read access',
373+
reason: 'Needed for operation',
374+
});
375+
376+
expect(response.result).toStrictEqual(decision);
377+
expect(existing.authorizeRequest).toHaveBeenCalledWith(
378+
'Allow read access',
379+
'Needed for operation',
380+
undefined,
381+
);
382+
});
383+
});

0 commit comments

Comments
 (0)