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.
@@ -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';
@@ -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
- collapsed: true,
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
- collapsed: true,
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
  } | {
@@ -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: "gray", paddingX: 1, children: [_jsx(Text, { color: "gray", children: '> ' }), _jsx(Text, { children: input }), status.status === 'running' ? _jsx(Text, { color: "cyan", children: " \u258D" }) : _jsx(Text, { children: "\u258D" })] })] }));
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
  }
@@ -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[];