diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx
new file mode 100644
index 0000000..3c74182
--- /dev/null
+++ b/src/tui/approve/Approve.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { Box, Text, useInput } from 'ink';
+import { computeHunks } from '../shared/diff.js';
+import { Hunk } from '../shared/types.js';
+
+export type Decision = 'accept' | 'reject';
+
+export interface ApproveProps {
+ fileName: string;
+ oldText: string;
+ newText: string;
+ onDecision: (d: Decision) => void;
+}
+
+export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps): React.ReactElement {
+ const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]);
+ const adds = hunks.filter((h) => h.kind === 'add').length;
+ const dels = hunks.filter((h) => h.kind === 'del').length;
+
+ useInput((input, key) => {
+ if (input === 'y') return onDecision('accept');
+ if (input === 'n' || input === 'q' || key.escape) return onDecision('reject');
+ });
+
+ return (
+
+
+ ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─
+
+
+ {hunks.map((h, i) => (
+
+ ))}
+
+
+ y accept n reject q reject & quit ESC reject
+
+
+ );
+}
+
+function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement {
+ const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' ';
+ const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined;
+ const lineNoStr = String(hunk.lineNo).padStart(4, ' ');
+ return (
+
+ {sigil} {lineNoStr} {hunk.text}
+
+ );
+}
diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx
new file mode 100644
index 0000000..129ebb5
--- /dev/null
+++ b/tests/tui/Approve.test.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'ink-testing-library';
+import { Approve } from '../../src/tui/approve/Approve.tsx';
+
+const OLD = 'PROGRAM PLC_PRG\nVAR\n counter : INT := 0;\nEND_VAR';
+const NEW = 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\n overflow : BOOL;\nEND_VAR';
+
+describe('', () => {
+ it('renders both deletions and additions in a unified diff', () => {
+ const { lastFrame } = render(
+ {}} />
+ );
+ const out = lastFrame()!;
+ expect(out).toContain('counter : INT := 0;');
+ expect(out).toContain('counter : DINT := 0;');
+ expect(out).toContain('overflow : BOOL;');
+ expect(out).toMatch(/Approve change\? PLC_PRG\.st/);
+ });
+
+ it('reports add/del totals in the header', () => {
+ const { lastFrame } = render(
+ {}} />
+ );
+ const out = lastFrame()!;
+ expect(out).toMatch(/\+ 2 lines.*− 1 lines/);
+ });
+
+ const flush = () => new Promise((r) => setImmediate(r));
+
+ it('calls onDecision("accept") when y is pressed', async () => {
+ const decision = vi.fn();
+ const { stdin } = render(
+
+ );
+ await flush();
+ stdin.write('y');
+ await flush();
+ expect(decision).toHaveBeenCalledWith('accept');
+ });
+
+ it('calls onDecision("reject") when n is pressed', async () => {
+ const decision = vi.fn();
+ const { stdin } = render(
+
+ );
+ await flush();
+ stdin.write('n');
+ await flush();
+ expect(decision).toHaveBeenCalledWith('reject');
+ });
+
+ it('calls onDecision("reject") when q is pressed', async () => {
+ const decision = vi.fn();
+ const { stdin } = render(
+
+ );
+ await flush();
+ stdin.write('q');
+ await flush();
+ expect(decision).toHaveBeenCalledWith('reject');
+ });
+
+ it('calls onDecision("reject") on escape', async () => {
+ const decision = vi.fn();
+ const { stdin } = render(
+
+ );
+ await flush();
+ stdin.write(String.fromCharCode(27));
+ await flush();
+ expect(decision).toHaveBeenCalledWith('reject');
+ });
+});