klyro 0.1.2 → 0.1.3
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.js +51 -4
- package/dist/cli/slash/parser.d.ts +3 -0
- package/dist/cli/slash/parser.js +4 -3
- package/dist/tui/app.d.ts +5 -0
- package/dist/tui/app.js +23 -2
- 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/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/agent/runtime.d.ts
CHANGED
|
@@ -61,6 +61,14 @@ export interface RunOptions {
|
|
|
61
61
|
*/
|
|
62
62
|
onEvent?: (ev: RuntimeEvent) => void;
|
|
63
63
|
}
|
|
64
|
+
/** A single plan step emitted by the agent. */
|
|
65
|
+
export interface PlanStep {
|
|
66
|
+
id: string;
|
|
67
|
+
title: string;
|
|
68
|
+
status: 'pending' | 'in_progress' | 'done' | 'failed' | 'skipped';
|
|
69
|
+
/** Optional files the step will touch (for L6 diff). */
|
|
70
|
+
files?: string[];
|
|
71
|
+
}
|
|
64
72
|
/** High-level event stream the runtime emits. Safe for UI consumption. */
|
|
65
73
|
export type RuntimeEvent = {
|
|
66
74
|
kind: 'step_start';
|
|
@@ -106,6 +114,17 @@ export type RuntimeEvent = {
|
|
|
106
114
|
text: string;
|
|
107
115
|
} | {
|
|
108
116
|
kind: 'aborted';
|
|
117
|
+
} | {
|
|
118
|
+
kind: 'plan_update';
|
|
119
|
+
plan: PlanStep[];
|
|
120
|
+
} | {
|
|
121
|
+
kind: 'file_changed';
|
|
122
|
+
path: string;
|
|
123
|
+
op: 'created' | 'modified' | 'deleted';
|
|
124
|
+
} | {
|
|
125
|
+
kind: 'verification_failed';
|
|
126
|
+
step: string;
|
|
127
|
+
reason: string;
|
|
109
128
|
};
|
|
110
129
|
export interface RunResult {
|
|
111
130
|
status: 'complete' | 'max_steps' | 'aborted' | 'no_final';
|
package/dist/agent/runtime.js
CHANGED
|
@@ -190,6 +190,11 @@ export async function run(opts, deps) {
|
|
|
190
190
|
telemetry.recordError(`${code}: ${call.name}`);
|
|
191
191
|
}
|
|
192
192
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
|
|
193
|
+
if (obs.ok) {
|
|
194
|
+
const fileChanged = inferFileChanged(call.name, call.input, obs.value);
|
|
195
|
+
if (fileChanged)
|
|
196
|
+
emit?.({ kind: 'file_changed', path: fileChanged.path, op: fileChanged.op });
|
|
197
|
+
}
|
|
193
198
|
}
|
|
194
199
|
emit?.({ kind: 'step_end', step: steps });
|
|
195
200
|
}
|
|
@@ -207,6 +212,28 @@ function redactOutput(v) {
|
|
|
207
212
|
return v; // structured outputs are not redacted wholesale
|
|
208
213
|
return v;
|
|
209
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Best-effort inference of "this tool call changed a file" for the
|
|
217
|
+
* file_changed event. Returns null when we don't have a clear answer.
|
|
218
|
+
*
|
|
219
|
+
* Today: write_file, edit_file, write_file (truncate via empty content).
|
|
220
|
+
* Tomorrow: shell_exec that ran `rm` or `git mv` would need to grep
|
|
221
|
+
* the output, but that's out of scope.
|
|
222
|
+
*/
|
|
223
|
+
function inferFileChanged(toolName, input, output) {
|
|
224
|
+
const inPath = typeof input.path === 'string' ? input.path : null;
|
|
225
|
+
if (!inPath)
|
|
226
|
+
return null;
|
|
227
|
+
if (toolName === 'write_file' || toolName === 'edit_file') {
|
|
228
|
+
// Distinguish create vs modify by the output shape: write_file returns
|
|
229
|
+
// { path, bytesWritten }; edit_file returns { path, replacements, diff }.
|
|
230
|
+
// Both are "modified" semantically; we don't have the pre-state easily
|
|
231
|
+
// from inside the runtime. A more precise implementation would stat the
|
|
232
|
+
// file before/after the call. For now: treat both as 'modified'.
|
|
233
|
+
return { path: inPath, op: 'modified' };
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
210
237
|
export function defaultSystemPrompt(ctx) {
|
|
211
238
|
const base = [
|
|
212
239
|
'You are Klyro, an autonomous coding harness. You solve the user\'s task by',
|
package/dist/cli/repl.js
CHANGED
|
@@ -14,6 +14,8 @@ import { builtinRegistry } from '../tools/registry.js';
|
|
|
14
14
|
import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
|
|
15
15
|
import { buildLevel6Context } from '../context/level6.js';
|
|
16
16
|
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
17
|
+
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
18
|
+
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
17
19
|
function readEnv(name, fallback) {
|
|
18
20
|
const v = process.env[name];
|
|
19
21
|
return v && v.length > 0 ? v : fallback;
|
|
@@ -30,7 +32,6 @@ export async function startRepl(opts = {}) {
|
|
|
30
32
|
const registry = builtinRegistry();
|
|
31
33
|
const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
|
|
32
34
|
const adapter = httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
|
|
33
|
-
const approval = opts.nonInteractive ? new DenyAllApprovalPrompt() : new StdinApprovalPrompt();
|
|
34
35
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
35
36
|
const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
36
37
|
const systemPromptFn = (_ctx) => {
|
|
@@ -40,13 +41,22 @@ export async function startRepl(opts = {}) {
|
|
|
40
41
|
};
|
|
41
42
|
const ac = new AbortController();
|
|
42
43
|
process.on('SIGINT', () => ac.abort());
|
|
44
|
+
// When the TUI is mounted, use the inline Ink prompt. Otherwise
|
|
45
|
+
// fall back to stdin readline. The bridge is shared between the
|
|
46
|
+
// App and the runtime so the modal can resolve the runtime's ask().
|
|
47
|
+
const tuiBridge = new TuiApprovalBridge();
|
|
48
|
+
const approval = opts.nonInteractive
|
|
49
|
+
? new DenyAllApprovalPrompt()
|
|
50
|
+
: (process.stdin.isTTY ? tuiBridge : new StdinApprovalPrompt());
|
|
43
51
|
let inflight = null;
|
|
44
52
|
let transcriptRef = [];
|
|
45
53
|
let lastStatus = null;
|
|
46
54
|
const app = render(React.createElement(App, {
|
|
47
55
|
initialModel: model,
|
|
48
56
|
maxSteps: opts.maxSteps ?? 30,
|
|
57
|
+
cwd,
|
|
49
58
|
initialStatus: { status: 'idle' },
|
|
59
|
+
approvalBridge: tuiBridge,
|
|
50
60
|
onPrompt: async (text) => {
|
|
51
61
|
inflight = runWithBridge(text);
|
|
52
62
|
await inflight;
|
|
@@ -97,7 +107,7 @@ export async function startRepl(opts = {}) {
|
|
|
97
107
|
name: ev.name,
|
|
98
108
|
id_call: ev.id,
|
|
99
109
|
args: JSON.stringify(ev.input, null, 2),
|
|
100
|
-
|
|
110
|
+
status: 'running',
|
|
101
111
|
});
|
|
102
112
|
activeCallId = null;
|
|
103
113
|
activeCallName = null;
|
|
@@ -122,12 +132,30 @@ export async function startRepl(opts = {}) {
|
|
|
122
132
|
result: typeof ev.output === 'string' ? ev.output : JSON.stringify(ev.output, null, 2),
|
|
123
133
|
isError: ev.isError,
|
|
124
134
|
latencyMs: ev.latencyMs,
|
|
125
|
-
|
|
135
|
+
status: ev.isError ? 'error' : 'done',
|
|
126
136
|
});
|
|
127
137
|
}
|
|
128
138
|
else if (ev.kind === 'usage') {
|
|
129
139
|
appG.__klyroAppStatus?.({ usageInput: ev.input, usageOutput: ev.output });
|
|
130
140
|
}
|
|
141
|
+
else if (ev.kind === 'plan_update') {
|
|
142
|
+
appG.__klyroAppPlan?.(ev.plan);
|
|
143
|
+
}
|
|
144
|
+
else if (ev.kind === 'file_changed') {
|
|
145
|
+
appG.__klyroAppAppend?.({
|
|
146
|
+
id: `fc-${ev.path}-${Date.now()}`,
|
|
147
|
+
kind: 'file_changed',
|
|
148
|
+
path: ev.path,
|
|
149
|
+
op: ev.op,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (ev.kind === 'verification_failed') {
|
|
153
|
+
appG.__klyroAppAppend?.({
|
|
154
|
+
id: `vf-${ev.step}-${Date.now()}`,
|
|
155
|
+
kind: 'error',
|
|
156
|
+
message: `verification failed at ${ev.step}: ${ev.reason}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
131
159
|
else if (ev.kind === 'aborted') {
|
|
132
160
|
appG.__klyroAppStatus?.({ status: 'aborted' });
|
|
133
161
|
}
|
|
@@ -169,8 +197,27 @@ export async function startRepl(opts = {}) {
|
|
|
169
197
|
}
|
|
170
198
|
return;
|
|
171
199
|
}
|
|
200
|
+
case 'diff': {
|
|
201
|
+
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
202
|
+
if (!r.ok) {
|
|
203
|
+
appG.__klyroAppAppend?.({
|
|
204
|
+
id: `diff-err-${Date.now()}`,
|
|
205
|
+
kind: 'error',
|
|
206
|
+
message: `git_diff failed: ${r.error.message ?? r.error.code}`,
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const out = r.value;
|
|
211
|
+
const hunks = parseUnifiedDiff(out.diff);
|
|
212
|
+
appG.__klyroAppAppend?.({
|
|
213
|
+
id: `diff-${Date.now()}`,
|
|
214
|
+
kind: 'diff',
|
|
215
|
+
hunks,
|
|
216
|
+
summary: `${out.patchedFiles.length} file(s) changed${out.stat ? ' — ' + out.stat.split('\n').pop() : ''}`,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
172
220
|
case 'compact':
|
|
173
|
-
case 'diff':
|
|
174
221
|
case 'model':
|
|
175
222
|
appG.__klyroAppAppend?.({
|
|
176
223
|
id: `stub-${Date.now()}`,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* /compact — ask the agent to compact its own context
|
|
7
7
|
* /model <id> — switch the active model mid-session
|
|
8
8
|
* /diff — show working-tree diff (git diff)
|
|
9
|
+
* /plan — toggle the plan view (if a plan is loaded)
|
|
9
10
|
* /status — show session status (model, steps, usage)
|
|
10
11
|
* /quit — exit the REPL
|
|
11
12
|
* /help — list available commands
|
|
@@ -22,6 +23,8 @@ export type SlashCommand = {
|
|
|
22
23
|
model: string;
|
|
23
24
|
} | {
|
|
24
25
|
kind: 'diff';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'plan';
|
|
25
28
|
} | {
|
|
26
29
|
kind: 'status';
|
|
27
30
|
} | {
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* /compact — ask the agent to compact its own context
|
|
7
7
|
* /model <id> — switch the active model mid-session
|
|
8
8
|
* /diff — show working-tree diff (git diff)
|
|
9
|
+
* /plan — toggle the plan view (if a plan is loaded)
|
|
9
10
|
* /status — show session status (model, steps, usage)
|
|
10
11
|
* /quit — exit the REPL
|
|
11
12
|
* /help — list available commands
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* Anything not starting with "/" is a regular prompt and yields
|
|
14
15
|
* { kind: 'prompt', text }.
|
|
15
16
|
*/
|
|
16
|
-
const KNOWN = ['clear', 'compact', 'model', 'diff', 'status', 'quit', 'help'];
|
|
17
|
+
const KNOWN = ['clear', 'compact', 'model', 'diff', 'plan', 'status', 'quit', 'help'];
|
|
17
18
|
export function parse(input) {
|
|
18
19
|
const trimmed = input.trim();
|
|
19
20
|
if (!trimmed.startsWith('/')) {
|
|
@@ -26,6 +27,7 @@ export function parse(input) {
|
|
|
26
27
|
case 'clear': return { kind: 'clear' };
|
|
27
28
|
case 'compact': return { kind: 'compact' };
|
|
28
29
|
case 'diff': return { kind: 'diff' };
|
|
30
|
+
case 'plan': return { kind: 'plan' };
|
|
29
31
|
case 'status': return { kind: 'status' };
|
|
30
32
|
case 'quit':
|
|
31
33
|
case 'exit':
|
|
@@ -38,8 +40,7 @@ export function parse(input) {
|
|
|
38
40
|
return { kind: 'unknown', raw: trimmed };
|
|
39
41
|
return { kind: 'model', model: rest };
|
|
40
42
|
}
|
|
41
|
-
default:
|
|
42
|
-
return { kind: 'unknown', raw: trimmed };
|
|
43
|
+
default: return { kind: 'unknown', raw: trimmed };
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
export function listCommands() {
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { type StatusSnapshot } from './status.js';
|
|
15
15
|
import { type TranscriptItem } from './transcript.js';
|
|
16
|
+
import { TuiApprovalBridge } from './approval.js';
|
|
16
17
|
export interface AppProps {
|
|
17
18
|
initialModel: string;
|
|
18
19
|
maxSteps: number;
|
|
20
|
+
/** Working directory to display in the header. */
|
|
21
|
+
cwd: string;
|
|
19
22
|
/** Called when the user submits a non-slash prompt. */
|
|
20
23
|
onPrompt: (text: string) => void | Promise<void>;
|
|
21
24
|
/** Called when the user types a slash command. */
|
|
@@ -23,5 +26,7 @@ export interface AppProps {
|
|
|
23
26
|
/** Initial state (e.g. when resuming a session). */
|
|
24
27
|
initialTranscript?: TranscriptItem[];
|
|
25
28
|
initialStatus?: Partial<StatusSnapshot>;
|
|
29
|
+
/** Optional approval bridge — when set, the modal prompts inline. */
|
|
30
|
+
approvalBridge?: TuiApprovalBridge;
|
|
26
31
|
}
|
|
27
32
|
export declare function App(props: AppProps): React.JSX.Element;
|
package/dist/tui/app.js
CHANGED
|
@@ -15,6 +15,9 @@ import { useState, useCallback, useEffect } 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,28 @@ 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
49
|
setTranscript((prev) => [...prev, item]);
|
|
39
50
|
}, []);
|
|
40
51
|
useEffect(() => {
|
|
41
52
|
globalThis.__klyroAppAppend = append;
|
|
42
53
|
globalThis.__klyroAppStatus = (s) => setStatus((prev) => ({ ...prev, ...s }));
|
|
54
|
+
globalThis.__klyroAppPlan = (p) => {
|
|
55
|
+
setPlan(p);
|
|
56
|
+
setPlanExpanded(true);
|
|
57
|
+
};
|
|
43
58
|
return () => {
|
|
44
59
|
delete globalThis.__klyroAppAppend;
|
|
45
60
|
delete globalThis.__klyroAppStatus;
|
|
61
|
+
delete globalThis.__klyroAppPlan;
|
|
46
62
|
};
|
|
47
63
|
}, [append]);
|
|
48
64
|
useInput((inputStr, key) => {
|
|
49
|
-
if (status.status === 'running')
|
|
65
|
+
if (status.status === 'running' || awaitingApproval)
|
|
50
66
|
return;
|
|
51
67
|
if (key.return) {
|
|
52
68
|
const value = input.trim();
|
|
@@ -58,6 +74,11 @@ export function App(props) {
|
|
|
58
74
|
if (cmd.kind === 'prompt') {
|
|
59
75
|
void props.onPrompt(cmd.text);
|
|
60
76
|
}
|
|
77
|
+
else if (cmd.kind === 'plan') {
|
|
78
|
+
// Local UI command — toggle the plan view inline.
|
|
79
|
+
if (plan.length > 0)
|
|
80
|
+
setPlanExpanded((v) => !v);
|
|
81
|
+
}
|
|
61
82
|
else {
|
|
62
83
|
void props.onSlash(cmd);
|
|
63
84
|
}
|
|
@@ -75,5 +96,5 @@ export function App(props) {
|
|
|
75
96
|
setInput((v) => v + inputStr);
|
|
76
97
|
}
|
|
77
98
|
});
|
|
78
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(StatusLine, { snapshot: status }), _jsx(Transcript, { items: transcript }), _jsxs(Box, { borderStyle: "single", borderColor:
|
|
99
|
+
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
100
|
}
|
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[];
|