klyro 0.1.2 → 0.1.4
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/dist/agent/runtime.d.ts +19 -0
- package/dist/agent/runtime.js +27 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +214 -47
- package/dist/cli/slash/parser.d.ts +3 -0
- package/dist/cli/slash/parser.js +4 -3
- package/dist/index.js +35 -2
- package/dist/tui/app.d.ts +12 -0
- package/dist/tui/app.js +48 -6
- package/dist/tui/app.test.js +8 -8
- package/dist/tui/approval.d.ts +42 -0
- package/dist/tui/approval.js +76 -0
- package/dist/tui/approval.test.d.ts +1 -0
- package/dist/tui/approval.test.js +93 -0
- package/dist/tui/diff-parser.d.ts +18 -0
- package/dist/tui/diff-parser.js +73 -0
- package/dist/tui/diff.d.ts +31 -0
- package/dist/tui/diff.js +25 -0
- package/dist/tui/diff.test.d.ts +1 -0
- package/dist/tui/diff.test.js +86 -0
- package/dist/tui/header.d.ts +20 -0
- package/dist/tui/header.js +16 -0
- package/dist/tui/header.test.d.ts +1 -0
- package/dist/tui/header.test.js +34 -0
- package/dist/tui/plan.d.ts +24 -0
- package/dist/tui/plan.js +25 -0
- package/dist/tui/plan.test.d.ts +1 -0
- package/dist/tui/plan.test.js +42 -0
- package/dist/tui/snapshot.test.d.ts +6 -0
- package/dist/tui/snapshot.test.js +87 -0
- package/dist/tui/transcript.d.ts +16 -3
- package/dist/tui/transcript.js +49 -3
- package/dist/tui/transcript.test.js +64 -16
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -11,10 +11,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
* RuntimeEvents via the onEvent callback wired by cli/repl.ts and
|
|
12
12
|
* translates them into transcript/status updates.
|
|
13
13
|
*/
|
|
14
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
14
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
15
15
|
import { Box, Text, useInput } from 'ink';
|
|
16
16
|
import { StatusLine } from './status.js';
|
|
17
17
|
import { Transcript } from './transcript.js';
|
|
18
|
+
import { Header } from './header.js';
|
|
19
|
+
import { ApprovalModal, TuiApprovalBridge } from './approval.js';
|
|
20
|
+
import { PlanView } from './plan.js';
|
|
18
21
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
19
22
|
let _itemCounter = 0;
|
|
20
23
|
function nextId(prefix) {
|
|
@@ -24,6 +27,10 @@ function nextId(prefix) {
|
|
|
24
27
|
export function App(props) {
|
|
25
28
|
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
26
29
|
const [input, setInput] = useState('');
|
|
30
|
+
const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
|
|
31
|
+
const [awaitingApproval, setAwaitingApproval] = useState(false);
|
|
32
|
+
const [plan, setPlan] = useState([]);
|
|
33
|
+
const [planExpanded, setPlanExpanded] = useState(false);
|
|
27
34
|
const [status, setStatus] = useState({
|
|
28
35
|
model: props.initialModel,
|
|
29
36
|
step: 0,
|
|
@@ -34,19 +41,49 @@ export function App(props) {
|
|
|
34
41
|
status: 'idle',
|
|
35
42
|
...props.initialStatus,
|
|
36
43
|
});
|
|
44
|
+
// Track bridge state to disable the input box while a prompt is up.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
return bridge.subscribe((p) => setAwaitingApproval(p !== null));
|
|
47
|
+
}, [bridge]);
|
|
37
48
|
const append = useCallback((item) => {
|
|
38
|
-
setTranscript((prev) =>
|
|
49
|
+
setTranscript((prev) => {
|
|
50
|
+
const last = prev[prev.length - 1];
|
|
51
|
+
// Only coalesce when IDs match — separate turns have different IDs
|
|
52
|
+
if (last?.kind === 'text' &&
|
|
53
|
+
item.kind === 'text' &&
|
|
54
|
+
last.role === 'assistant' &&
|
|
55
|
+
item.role === 'assistant' &&
|
|
56
|
+
last.id === item.id) {
|
|
57
|
+
return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
|
|
58
|
+
}
|
|
59
|
+
return [...prev, item];
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
const updateStatus = useCallback((s) => {
|
|
63
|
+
setStatus((prev) => ({ ...prev, ...s }));
|
|
39
64
|
}, []);
|
|
65
|
+
const updatePlan = useCallback((p) => {
|
|
66
|
+
setPlan(p);
|
|
67
|
+
setPlanExpanded(true);
|
|
68
|
+
}, []);
|
|
69
|
+
// Stabilize onMounted to avoid re-installing hooks on every parent re-render
|
|
70
|
+
const onMountedRef = useRef(props.onMounted);
|
|
71
|
+
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
40
72
|
useEffect(() => {
|
|
73
|
+
// Instance-local hooks via callback (preferred)
|
|
74
|
+
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
75
|
+
// Global compat hooks for tests / legacy callers (single instance at a time)
|
|
41
76
|
globalThis.__klyroAppAppend = append;
|
|
42
|
-
globalThis.__klyroAppStatus =
|
|
77
|
+
globalThis.__klyroAppStatus = updateStatus;
|
|
78
|
+
globalThis.__klyroAppPlan = updatePlan;
|
|
43
79
|
return () => {
|
|
44
80
|
delete globalThis.__klyroAppAppend;
|
|
45
81
|
delete globalThis.__klyroAppStatus;
|
|
82
|
+
delete globalThis.__klyroAppPlan;
|
|
46
83
|
};
|
|
47
|
-
}, [append]);
|
|
84
|
+
}, [append, updateStatus, updatePlan]);
|
|
48
85
|
useInput((inputStr, key) => {
|
|
49
|
-
if (status.status === 'running')
|
|
86
|
+
if (status.status === 'running' || awaitingApproval)
|
|
50
87
|
return;
|
|
51
88
|
if (key.return) {
|
|
52
89
|
const value = input.trim();
|
|
@@ -58,6 +95,11 @@ export function App(props) {
|
|
|
58
95
|
if (cmd.kind === 'prompt') {
|
|
59
96
|
void props.onPrompt(cmd.text);
|
|
60
97
|
}
|
|
98
|
+
else if (cmd.kind === 'plan') {
|
|
99
|
+
// Local UI command — toggle the plan view inline.
|
|
100
|
+
if (plan.length > 0)
|
|
101
|
+
setPlanExpanded((v) => !v);
|
|
102
|
+
}
|
|
61
103
|
else {
|
|
62
104
|
void props.onSlash(cmd);
|
|
63
105
|
}
|
|
@@ -75,5 +117,5 @@ export function App(props) {
|
|
|
75
117
|
setInput((v) => v + inputStr);
|
|
76
118
|
}
|
|
77
119
|
});
|
|
78
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(StatusLine, { snapshot: status }), _jsx(Transcript, { items: transcript }), _jsxs(Box, { borderStyle: "single", borderColor:
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(Header, { cwd: props.cwd, model: status.model, step: status.step, maxSteps: status.maxSteps }), _jsx(StatusLine, { snapshot: status }), plan.length > 0 ? (_jsx(PlanView, { steps: plan, expanded: planExpanded, onToggle: () => setPlanExpanded((v) => !v) })) : null, _jsx(Transcript, { items: transcript }), awaitingApproval ? _jsx(ApprovalModal, { bridge: bridge }) : null, _jsxs(Box, { borderStyle: "single", borderColor: awaitingApproval ? 'yellow' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "gray", children: awaitingApproval ? '! ' : '> ' }), _jsx(Text, { children: awaitingApproval ? '(awaiting approval — see above)' : input }), status.status === 'running' ? _jsx(Text, { color: "cyan", children: " \u258D" }) : _jsx(Text, { children: "\u258D" })] })] }));
|
|
79
121
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { render } from 'ink-testing-library';
|
|
|
4
4
|
import { App } from './app.js';
|
|
5
5
|
describe('App', () => {
|
|
6
6
|
it('shows the empty-state hint and the status line', () => {
|
|
7
|
-
const { lastFrame } = render(_jsx(App, { initialModel: "mock", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { } }));
|
|
7
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "mock", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
8
8
|
const out = lastFrame();
|
|
9
9
|
expect(out).toContain('mock');
|
|
10
10
|
expect(out).toMatch(/Type a prompt/);
|
|
@@ -13,12 +13,12 @@ describe('App', () => {
|
|
|
13
13
|
const items = [
|
|
14
14
|
{ id: '1', kind: 'text', text: 'seed', role: 'user' },
|
|
15
15
|
];
|
|
16
|
-
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { }, initialTranscript: items }));
|
|
16
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, initialTranscript: items }));
|
|
17
17
|
expect(lastFrame()).toContain('> seed');
|
|
18
18
|
});
|
|
19
19
|
it('honors initialStatus overrides', () => {
|
|
20
20
|
const overrides = { step: 5, repairs: 3, status: 'running' };
|
|
21
|
-
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { }, initialStatus: overrides }));
|
|
21
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, initialStatus: overrides }));
|
|
22
22
|
const out = lastFrame();
|
|
23
23
|
expect(out).toContain('5');
|
|
24
24
|
expect(out).toContain('10');
|
|
@@ -27,7 +27,7 @@ describe('App', () => {
|
|
|
27
27
|
});
|
|
28
28
|
it('installs and tears down the global bridge hooks', () => {
|
|
29
29
|
const g = globalThis;
|
|
30
|
-
const { unmount } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { } }));
|
|
30
|
+
const { unmount } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
31
31
|
expect(g.__klyroAppAppend).toBeTypeOf('function');
|
|
32
32
|
expect(g.__klyroAppStatus).toBeTypeOf('function');
|
|
33
33
|
unmount();
|
|
@@ -36,7 +36,7 @@ describe('App', () => {
|
|
|
36
36
|
});
|
|
37
37
|
it('submits a non-slash prompt via onPrompt', async () => {
|
|
38
38
|
const onPrompt = vi.fn(async () => { });
|
|
39
|
-
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: onPrompt, onSlash: async () => { } }));
|
|
39
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
|
|
40
40
|
stdin.write('hello world');
|
|
41
41
|
await new Promise((r) => setTimeout(r, 20));
|
|
42
42
|
stdin.write('\x0d');
|
|
@@ -45,7 +45,7 @@ describe('App', () => {
|
|
|
45
45
|
});
|
|
46
46
|
it('routes a slash command to onSlash', async () => {
|
|
47
47
|
const onSlash = vi.fn(async () => { });
|
|
48
|
-
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: onSlash }));
|
|
48
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: onSlash }));
|
|
49
49
|
stdin.write('/help');
|
|
50
50
|
await new Promise((r) => setTimeout(r, 20));
|
|
51
51
|
stdin.write('\x0d');
|
|
@@ -56,7 +56,7 @@ describe('App', () => {
|
|
|
56
56
|
});
|
|
57
57
|
it('ignores Enter while status is running', async () => {
|
|
58
58
|
const onPrompt = vi.fn(async () => { });
|
|
59
|
-
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: onPrompt, onSlash: async () => { }, initialStatus: { status: 'running' } }));
|
|
59
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, initialStatus: { status: 'running' } }));
|
|
60
60
|
stdin.write('hello');
|
|
61
61
|
await new Promise((r) => setTimeout(r, 20));
|
|
62
62
|
stdin.write('\x0d');
|
|
@@ -65,7 +65,7 @@ describe('App', () => {
|
|
|
65
65
|
});
|
|
66
66
|
it('routes /quit to onSlash as a quit command', async () => {
|
|
67
67
|
const onSlash = vi.fn(async () => { });
|
|
68
|
-
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: onSlash }));
|
|
68
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: onSlash }));
|
|
69
69
|
stdin.write('/quit');
|
|
70
70
|
await new Promise((r) => setTimeout(r, 20));
|
|
71
71
|
stdin.write('\x0d');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI approval prompt — a TUI-native replacement for StdinApprovalPrompt.
|
|
3
|
+
*
|
|
4
|
+
* Why not just keep StdinApprovalPrompt and use readline?
|
|
5
|
+
* Ink owns stdin via useInput. If readline is also attached to stdin,
|
|
6
|
+
* their events race and the user sees garbled or missing input.
|
|
7
|
+
*
|
|
8
|
+
* The runtime calls `ask(req)` from inside the agent loop and awaits the
|
|
9
|
+
* choice. This module sits between them:
|
|
10
|
+
*
|
|
11
|
+
* 1. The Ink App registers a resolver via setResolver() in useEffect.
|
|
12
|
+
* 2. When the runtime calls ask(), we stash the request and return
|
|
13
|
+
* a Promise.
|
|
14
|
+
* 3. The App polls getPending() on every render to show a modal.
|
|
15
|
+
* 4. The App's useInput handler sees a pending prompt and routes
|
|
16
|
+
* y/n/a to the resolver.
|
|
17
|
+
*
|
|
18
|
+
* Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
|
|
19
|
+
* module is only used when the TUI is active.
|
|
20
|
+
*/
|
|
21
|
+
import React from 'react';
|
|
22
|
+
import type { ApprovalChoice, ApprovalRequest } from '../policy/approval.js';
|
|
23
|
+
import type { ApprovalPrompt } from '../policy/approval.js';
|
|
24
|
+
interface PendingPrompt {
|
|
25
|
+
req: ApprovalRequest;
|
|
26
|
+
resolve: (choice: ApprovalChoice) => void;
|
|
27
|
+
}
|
|
28
|
+
declare class TuiApprovalBridge implements ApprovalPrompt {
|
|
29
|
+
private pending;
|
|
30
|
+
private listener;
|
|
31
|
+
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
32
|
+
/** Called by the App's useInput to consume the pending prompt. */
|
|
33
|
+
resolve(choice: ApprovalChoice): boolean;
|
|
34
|
+
getPending(): ApprovalRequest | null;
|
|
35
|
+
/** Used by useSyncExternalStore-ish wiring in <ApprovalModal/>. */
|
|
36
|
+
subscribe(listener: (p: PendingPrompt | null) => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
export { TuiApprovalBridge };
|
|
39
|
+
/** Mounted inside the Ink App. Renders a modal when a prompt is pending. */
|
|
40
|
+
export declare function ApprovalModal({ bridge }: {
|
|
41
|
+
bridge: TuiApprovalBridge;
|
|
42
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* TUI approval prompt — a TUI-native replacement for StdinApprovalPrompt.
|
|
4
|
+
*
|
|
5
|
+
* Why not just keep StdinApprovalPrompt and use readline?
|
|
6
|
+
* Ink owns stdin via useInput. If readline is also attached to stdin,
|
|
7
|
+
* their events race and the user sees garbled or missing input.
|
|
8
|
+
*
|
|
9
|
+
* The runtime calls `ask(req)` from inside the agent loop and awaits the
|
|
10
|
+
* choice. This module sits between them:
|
|
11
|
+
*
|
|
12
|
+
* 1. The Ink App registers a resolver via setResolver() in useEffect.
|
|
13
|
+
* 2. When the runtime calls ask(), we stash the request and return
|
|
14
|
+
* a Promise.
|
|
15
|
+
* 3. The App polls getPending() on every render to show a modal.
|
|
16
|
+
* 4. The App's useInput handler sees a pending prompt and routes
|
|
17
|
+
* y/n/a to the resolver.
|
|
18
|
+
*
|
|
19
|
+
* Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
|
|
20
|
+
* module is only used when the TUI is active.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useState } from 'react';
|
|
23
|
+
import { Box, Text, useInput } from 'ink';
|
|
24
|
+
class TuiApprovalBridge {
|
|
25
|
+
pending = null;
|
|
26
|
+
listener = null;
|
|
27
|
+
ask(req) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
this.pending = { req, resolve };
|
|
30
|
+
this.listener?.(this.pending);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Called by the App's useInput to consume the pending prompt. */
|
|
34
|
+
resolve(choice) {
|
|
35
|
+
if (!this.pending)
|
|
36
|
+
return false;
|
|
37
|
+
const p = this.pending;
|
|
38
|
+
this.pending = null;
|
|
39
|
+
this.listener?.(null);
|
|
40
|
+
p.resolve(choice);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
getPending() {
|
|
44
|
+
return this.pending?.req ?? null;
|
|
45
|
+
}
|
|
46
|
+
/** Used by useSyncExternalStore-ish wiring in <ApprovalModal/>. */
|
|
47
|
+
subscribe(listener) {
|
|
48
|
+
this.listener = listener;
|
|
49
|
+
return () => { this.listener = null; };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export { TuiApprovalBridge };
|
|
53
|
+
/** Mounted inside the Ink App. Renders a modal when a prompt is pending. */
|
|
54
|
+
export function ApprovalModal({ bridge }) {
|
|
55
|
+
const [pending, setPending] = useState(bridge.getPending());
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
return bridge.subscribe((p) => setPending(p?.req ?? null));
|
|
58
|
+
}, [bridge]);
|
|
59
|
+
useInput((inputStr, key) => {
|
|
60
|
+
if (!pending)
|
|
61
|
+
return;
|
|
62
|
+
const c = inputStr.toLowerCase();
|
|
63
|
+
if (c === 'y' || c === 'a' || c === 'd' || c === 'n') {
|
|
64
|
+
const choice = (c === 'y' || c === 'a') ? (c === 'a' ? 'always' : 'allow') : 'deny';
|
|
65
|
+
bridge.resolve(choice);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.return) {
|
|
69
|
+
bridge.resolve('deny');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (!pending)
|
|
74
|
+
return null;
|
|
75
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: ", pending.reason] }), pending.summary ? _jsxs(Text, { children: [" ", pending.summary] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] allow" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] always allow" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[d] deny" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
5
|
+
describe('TuiApprovalBridge', () => {
|
|
6
|
+
it('returns a pending prompt until resolve() is called', async () => {
|
|
7
|
+
const bridge = new TuiApprovalBridge();
|
|
8
|
+
expect(bridge.getPending()).toBeNull();
|
|
9
|
+
const choicePromise = bridge.ask({ toolName: 'shell_exec', reason: 'risky', summary: 'rm -rf' });
|
|
10
|
+
const req = bridge.getPending();
|
|
11
|
+
expect(req?.toolName).toBe('shell_exec');
|
|
12
|
+
bridge.resolve('deny');
|
|
13
|
+
await expect(choicePromise).resolves.toBe('deny');
|
|
14
|
+
expect(bridge.getPending()).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
it('resolve() is a no-op when no prompt is pending', () => {
|
|
17
|
+
const bridge = new TuiApprovalBridge();
|
|
18
|
+
expect(bridge.resolve('allow')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('subscribers are notified on pending change', () => {
|
|
21
|
+
const bridge = new TuiApprovalBridge();
|
|
22
|
+
const fn = vi.fn();
|
|
23
|
+
bridge.subscribe(fn);
|
|
24
|
+
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
25
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
26
|
+
bridge.resolve('allow');
|
|
27
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
28
|
+
return promise;
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('ApprovalModal', () => {
|
|
32
|
+
function setup() {
|
|
33
|
+
const bridge = new TuiApprovalBridge();
|
|
34
|
+
const { lastFrame, stdin } = render(_jsx(ApprovalModal, { bridge: bridge }));
|
|
35
|
+
return { bridge, lastFrame, stdin };
|
|
36
|
+
}
|
|
37
|
+
it('renders nothing when no prompt is pending', () => {
|
|
38
|
+
const { lastFrame } = setup();
|
|
39
|
+
expect(lastFrame()).toBe('');
|
|
40
|
+
});
|
|
41
|
+
it('renders the modal when a prompt is pending', async () => {
|
|
42
|
+
const { lastFrame, bridge } = setup();
|
|
43
|
+
const promise = bridge.ask({ toolName: 'shell_exec', reason: 'destructive', summary: 'rm -rf /' });
|
|
44
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
45
|
+
const out = lastFrame();
|
|
46
|
+
expect(out).toContain('approval needed');
|
|
47
|
+
expect(out).toContain('shell_exec');
|
|
48
|
+
expect(out).toContain('destructive');
|
|
49
|
+
expect(out).toContain('rm -rf /');
|
|
50
|
+
expect(out).toContain('allow');
|
|
51
|
+
expect(out).toContain('deny');
|
|
52
|
+
bridge.resolve('deny');
|
|
53
|
+
await promise;
|
|
54
|
+
});
|
|
55
|
+
it('"y" resolves to allow', async () => {
|
|
56
|
+
const { bridge, stdin } = setup();
|
|
57
|
+
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
58
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
59
|
+
stdin.write('y');
|
|
60
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
61
|
+
await expect(promise).resolves.toBe('allow');
|
|
62
|
+
});
|
|
63
|
+
it('"a" resolves to always', async () => {
|
|
64
|
+
const { bridge, stdin } = setup();
|
|
65
|
+
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
66
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
67
|
+
stdin.write('a');
|
|
68
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
69
|
+
await expect(promise).resolves.toBe('always');
|
|
70
|
+
});
|
|
71
|
+
it('"n" and "d" resolve to deny', async () => {
|
|
72
|
+
const { bridge: b1, stdin: s1 } = setup();
|
|
73
|
+
const p1 = b1.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
74
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
75
|
+
s1.write('n');
|
|
76
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
77
|
+
await expect(p1).resolves.toBe('deny');
|
|
78
|
+
const { bridge: b2, stdin: s2 } = setup();
|
|
79
|
+
const p2 = b2.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
80
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
81
|
+
s2.write('d');
|
|
82
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
83
|
+
await expect(p2).resolves.toBe('deny');
|
|
84
|
+
});
|
|
85
|
+
it('Enter resolves to deny', async () => {
|
|
86
|
+
const { bridge, stdin } = setup();
|
|
87
|
+
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
88
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
89
|
+
stdin.write('\x0d');
|
|
90
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
91
|
+
await expect(promise).resolves.toBe('deny');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny unified-diff parser.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset of `git diff` output that the runtime cares about:
|
|
5
|
+
* - `diff --git a/x b/x` headers
|
|
6
|
+
* - `+++ b/path` / `--- a/path` path lines
|
|
7
|
+
* - `@@ ... @@` hunk headers
|
|
8
|
+
* - `+`, `-`, ` ` (context) lines
|
|
9
|
+
*
|
|
10
|
+
* Skips:
|
|
11
|
+
* - `index ...` lines
|
|
12
|
+
* - `Binary files ... differ` lines
|
|
13
|
+
* - "no newline at end of file" markers
|
|
14
|
+
*
|
|
15
|
+
* Returns one DiffHunk per file. Lines are preserved in order.
|
|
16
|
+
*/
|
|
17
|
+
import type { DiffHunk } from './diff.js';
|
|
18
|
+
export declare function parseUnifiedDiff(raw: string): DiffHunk[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny unified-diff parser.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset of `git diff` output that the runtime cares about:
|
|
5
|
+
* - `diff --git a/x b/x` headers
|
|
6
|
+
* - `+++ b/path` / `--- a/path` path lines
|
|
7
|
+
* - `@@ ... @@` hunk headers
|
|
8
|
+
* - `+`, `-`, ` ` (context) lines
|
|
9
|
+
*
|
|
10
|
+
* Skips:
|
|
11
|
+
* - `index ...` lines
|
|
12
|
+
* - `Binary files ... differ` lines
|
|
13
|
+
* - "no newline at end of file" markers
|
|
14
|
+
*
|
|
15
|
+
* Returns one DiffHunk per file. Lines are preserved in order.
|
|
16
|
+
*/
|
|
17
|
+
export function parseUnifiedDiff(raw) {
|
|
18
|
+
const out = [];
|
|
19
|
+
let current = null;
|
|
20
|
+
for (const line of raw.split('\n')) {
|
|
21
|
+
if (line.startsWith('diff --git ')) {
|
|
22
|
+
if (current)
|
|
23
|
+
out.push(current);
|
|
24
|
+
current = null;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (line.startsWith('+++ ')) {
|
|
28
|
+
const path = stripDiffPrefix(line.slice(4));
|
|
29
|
+
if (path)
|
|
30
|
+
current = { path, lines: [] };
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (line.startsWith('--- ')) {
|
|
34
|
+
// Path on the "from" side — we prefer the "to" path so the
|
|
35
|
+
// heading matches the file the user is editing.
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith('@@')) {
|
|
39
|
+
if (current)
|
|
40
|
+
current.lines.push({ kind: 'header', text: line });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!current)
|
|
44
|
+
continue;
|
|
45
|
+
if (line.startsWith('+')) {
|
|
46
|
+
current.lines.push({ kind: 'add', text: line.slice(1) });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (line.startsWith('-')) {
|
|
50
|
+
current.lines.push({ kind: 'remove', text: line.slice(1) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (line.startsWith(' ')) {
|
|
54
|
+
current.lines.push({ kind: 'context', text: line.slice(1) });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (line.startsWith('index ') || line.startsWith('Binary files'))
|
|
58
|
+
continue;
|
|
59
|
+
if (line === '\')
|
|
60
|
+
continue;
|
|
61
|
+
// Unknown line — keep as context so we don't lose info.
|
|
62
|
+
current.lines.push({ kind: 'context', text: line });
|
|
63
|
+
}
|
|
64
|
+
if (current)
|
|
65
|
+
out.push(current);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function stripDiffPrefix(s) {
|
|
69
|
+
// "b/src/foo.ts" → "src/foo.ts"; "a/src/foo.ts" → "src/foo.ts"
|
|
70
|
+
if (s.startsWith('b/') || s.startsWith('a/'))
|
|
71
|
+
return s.slice(2);
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiffView — a scrollable unified diff display.
|
|
3
|
+
*
|
|
4
|
+
* Data model: a list of `DiffHunk` (one per file). Each hunk has a path
|
|
5
|
+
* and a list of `DiffLine` with kind (add/remove/context/header).
|
|
6
|
+
*
|
|
7
|
+
* src/auth/service.ts
|
|
8
|
+
* - const old = 1;
|
|
9
|
+
* + const old = 2;
|
|
10
|
+
* const same = true;
|
|
11
|
+
* + // new line
|
|
12
|
+
*
|
|
13
|
+
* Lines are truncated to 200 chars and color-coded. The view returns
|
|
14
|
+
* null when there's no diff to show.
|
|
15
|
+
*/
|
|
16
|
+
import React from 'react';
|
|
17
|
+
export type DiffLineKind = 'add' | 'remove' | 'context' | 'header';
|
|
18
|
+
export interface DiffLine {
|
|
19
|
+
kind: DiffLineKind;
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DiffHunk {
|
|
23
|
+
path: string;
|
|
24
|
+
lines: DiffLine[];
|
|
25
|
+
}
|
|
26
|
+
export interface DiffViewProps {
|
|
27
|
+
hunks: DiffHunk[];
|
|
28
|
+
/** Optional: total file count summary. */
|
|
29
|
+
summary?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function DiffView({ hunks, summary }: DiffViewProps): React.JSX.Element | null;
|
package/dist/tui/diff.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const LINE_COLORS = {
|
|
4
|
+
add: 'green',
|
|
5
|
+
remove: 'red',
|
|
6
|
+
context: 'gray',
|
|
7
|
+
header: 'cyan',
|
|
8
|
+
};
|
|
9
|
+
const GLYPHS = {
|
|
10
|
+
add: '+',
|
|
11
|
+
remove: '-',
|
|
12
|
+
context: ' ',
|
|
13
|
+
header: '@',
|
|
14
|
+
};
|
|
15
|
+
function truncate(s, max) {
|
|
16
|
+
if (s.length <= max)
|
|
17
|
+
return s;
|
|
18
|
+
return s.slice(0, max) + '…';
|
|
19
|
+
}
|
|
20
|
+
export function DiffView({ hunks, summary }) {
|
|
21
|
+
if (hunks.length === 0) {
|
|
22
|
+
return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, marginY: 1, children: _jsx(Text, { color: "gray", children: "(no working-tree changes)" }) }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1, children: [summary ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\uD83D\uDCDD Diff " }), _jsxs(Text, { color: "gray", children: [" ", summary] })] })) : null, hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u2500\u2500 ", h.path, " "] }), _jsxs(Text, { color: "gray", children: ["(", h.lines.filter((l) => l.kind === 'add').length, "+ /", ' ', h.lines.filter((l) => l.kind === 'remove').length, "-)"] })] }), h.lines.map((l, j) => (_jsxs(Box, { children: [_jsxs(Text, { color: LINE_COLORS[l.kind], children: [GLYPHS[l.kind], " "] }), _jsx(Text, { color: LINE_COLORS[l.kind], children: truncate(l.text, 200) })] }, j)))] }, `${h.path}-${i}`)))] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { DiffView } from './diff.js';
|
|
5
|
+
import { parseUnifiedDiff } from './diff-parser.js';
|
|
6
|
+
const SAMPLE = `diff --git a/src/foo.ts b/src/foo.ts
|
|
7
|
+
index 1234..5678 100644
|
|
8
|
+
--- a/src/foo.ts
|
|
9
|
+
+++ b/src/foo.ts
|
|
10
|
+
@@ -1,3 +1,4 @@
|
|
11
|
+
const a = 1;
|
|
12
|
+
-const b = 2;
|
|
13
|
+
+const b = 3;
|
|
14
|
+
+const c = 4;
|
|
15
|
+
export {};
|
|
16
|
+
diff --git a/src/bar.ts b/src/bar.ts
|
|
17
|
+
index aaaa..bbbb 100644
|
|
18
|
+
--- a/src/bar.ts
|
|
19
|
+
+++ b/src/bar.ts
|
|
20
|
+
@@ -1,2 +1,2 @@
|
|
21
|
+
-const x = 'old';
|
|
22
|
+
+const x = 'new';
|
|
23
|
+
`;
|
|
24
|
+
describe('parseUnifiedDiff', () => {
|
|
25
|
+
it('returns one hunk per file', () => {
|
|
26
|
+
const hunks = parseUnifiedDiff(SAMPLE);
|
|
27
|
+
expect(hunks).toHaveLength(2);
|
|
28
|
+
expect(hunks[0].path).toBe('src/foo.ts');
|
|
29
|
+
expect(hunks[1].path).toBe('src/bar.ts');
|
|
30
|
+
});
|
|
31
|
+
it('classifies add/remove/context/header lines', () => {
|
|
32
|
+
const hunks = parseUnifiedDiff(SAMPLE);
|
|
33
|
+
const lines = hunks[0].lines;
|
|
34
|
+
expect(lines[0].kind).toBe('header');
|
|
35
|
+
expect(lines[1].kind).toBe('context');
|
|
36
|
+
expect(lines[2].kind).toBe('remove');
|
|
37
|
+
expect(lines[3].kind).toBe('add');
|
|
38
|
+
expect(lines[4].kind).toBe('add');
|
|
39
|
+
});
|
|
40
|
+
it('skips index/binary/no-newline markers', () => {
|
|
41
|
+
const hunks = parseUnifiedDiff(SAMPLE);
|
|
42
|
+
for (const h of hunks) {
|
|
43
|
+
for (const l of h.lines) {
|
|
44
|
+
expect(l.text).not.toMatch(/^index /);
|
|
45
|
+
expect(l.text).not.toMatch(/Binary files/);
|
|
46
|
+
expect(l.text).not.toMatch(/No newline at end/);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('returns empty array for empty input', () => {
|
|
51
|
+
expect(parseUnifiedDiff('')).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('DiffView', () => {
|
|
55
|
+
it('shows an empty-state when hunks is empty', () => {
|
|
56
|
+
const { lastFrame } = render(_jsx(DiffView, { hunks: [] }));
|
|
57
|
+
expect(lastFrame()).toContain('no working-tree changes');
|
|
58
|
+
});
|
|
59
|
+
it('renders a file header and line glyphs', () => {
|
|
60
|
+
const hunks = [
|
|
61
|
+
{
|
|
62
|
+
path: 'src/foo.ts',
|
|
63
|
+
lines: [
|
|
64
|
+
{ kind: 'header', text: '@@ -1 +1 @@' },
|
|
65
|
+
{ kind: 'context', text: 'const a = 1;' },
|
|
66
|
+
{ kind: 'remove', text: 'const b = 2;' },
|
|
67
|
+
{ kind: 'add', text: 'const b = 3;' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const { lastFrame } = render(_jsx(DiffView, { hunks: hunks, summary: "1 file changed" }));
|
|
72
|
+
const out = lastFrame();
|
|
73
|
+
expect(out).toContain('src/foo.ts');
|
|
74
|
+
expect(out).toContain('+ const b = 3;');
|
|
75
|
+
expect(out).toContain('- const b = 2;');
|
|
76
|
+
expect(out).toContain('1 file changed');
|
|
77
|
+
});
|
|
78
|
+
it('truncates long lines', () => {
|
|
79
|
+
const long = 'x'.repeat(500);
|
|
80
|
+
const hunks = [
|
|
81
|
+
{ path: 'f.ts', lines: [{ kind: 'add', text: long }] },
|
|
82
|
+
];
|
|
83
|
+
const { lastFrame } = render(_jsx(DiffView, { hunks: hunks }));
|
|
84
|
+
expect(lastFrame()).toContain('…');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header — top-of-screen chrome: app name, cwd, model, step counter.
|
|
3
|
+
*
|
|
4
|
+
* Renders a single line with two padded cells separated by a vertical bar.
|
|
5
|
+
* Pure presentational; takes all data as props.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
export interface HeaderProps {
|
|
9
|
+
/** Working directory, displayed abbreviated (last 2 path segments) if long. */
|
|
10
|
+
cwd: string;
|
|
11
|
+
/** Model id, e.g. "claude-sonnet" or "gpt-4o". */
|
|
12
|
+
model: string;
|
|
13
|
+
/** Current step number (0 when not started). */
|
|
14
|
+
step: number;
|
|
15
|
+
/** Max steps for this run (used to render "step / max"). */
|
|
16
|
+
maxSteps: number;
|
|
17
|
+
}
|
|
18
|
+
/** Abbreviate a path to the last two segments so long paths don't overflow. */
|
|
19
|
+
export declare function abbrevPath(p: string, maxChars?: number): string;
|
|
20
|
+
export declare function Header({ cwd, model, step, maxSteps }: HeaderProps): React.JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/** Abbreviate a path to the last two segments so long paths don't overflow. */
|
|
4
|
+
export function abbrevPath(p, maxChars = 60) {
|
|
5
|
+
if (p.length <= maxChars)
|
|
6
|
+
return p;
|
|
7
|
+
// Detect the original separator style and rejoin with it.
|
|
8
|
+
const sep = p.includes('\\') ? '\\' : '/';
|
|
9
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
10
|
+
if (parts.length <= 2)
|
|
11
|
+
return p;
|
|
12
|
+
return '…' + sep + parts.slice(-2).join(sep);
|
|
13
|
+
}
|
|
14
|
+
export function Header({ cwd, model, step, maxSteps }) {
|
|
15
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "KLYRO" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "gray", children: abbrevPath(cwd) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: model }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: step >= maxSteps ? 'red' : 'gray', children: ["step ", step, "/", maxSteps] })] })] }));
|
|
16
|
+
}
|