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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { Header, abbrevPath } from './header.js';
|
|
5
|
+
describe('abbrevPath', () => {
|
|
6
|
+
it('passes through short paths unchanged', () => {
|
|
7
|
+
expect(abbrevPath('/tmp/x')).toBe('/tmp/x');
|
|
8
|
+
expect(abbrevPath('C:\\projects\\app')).toBe('C:\\projects\\app');
|
|
9
|
+
});
|
|
10
|
+
it('keeps the last two segments of long paths', () => {
|
|
11
|
+
// 7 segments → last two are "src" and "index.ts".
|
|
12
|
+
expect(abbrevPath('/home/user/projects/some-really-deeply-nested-name/my-app/src/index.ts'))
|
|
13
|
+
.toBe('…/src/index.ts');
|
|
14
|
+
});
|
|
15
|
+
it('handles windows-style backslashes in long paths', () => {
|
|
16
|
+
const longWin = 'C:\\Users\\L.Siddhartha\\projects\\some-really-deep-name\\klyro-thing\\src\\index.ts';
|
|
17
|
+
expect(abbrevPath(longWin)).toBe('…\\src\\index.ts');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('Header', () => {
|
|
21
|
+
it('shows the app name, cwd, model, and step counter', () => {
|
|
22
|
+
const { lastFrame } = render(_jsx(Header, { cwd: "/tmp/proj", model: "claude-sonnet", step: 3, maxSteps: 30 }));
|
|
23
|
+
const out = lastFrame();
|
|
24
|
+
expect(out).toContain('KLYRO');
|
|
25
|
+
expect(out).toContain('/tmp/proj');
|
|
26
|
+
expect(out).toContain('claude-sonnet');
|
|
27
|
+
expect(out).toMatch(/step 3\/30/);
|
|
28
|
+
});
|
|
29
|
+
it('highlights the step counter when at or above maxSteps', () => {
|
|
30
|
+
const { lastFrame } = render(_jsx(Header, { cwd: "/p", model: "m", step: 30, maxSteps: 30 }));
|
|
31
|
+
const out = lastFrame();
|
|
32
|
+
expect(out).toMatch(/step 30\/30/);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlanView — a scrollable, collapsible view of the agent's plan.
|
|
3
|
+
*
|
|
4
|
+
* Data model: a list of PlanStep objects with status. The runtime
|
|
5
|
+
* emits `plan_update` events; the TUI aggregates them into a single
|
|
6
|
+
* current plan and renders the steps with status glyphs.
|
|
7
|
+
*
|
|
8
|
+
* ◯ read src/auth/service.ts
|
|
9
|
+
* ● edit src/auth/service.ts (in progress)
|
|
10
|
+
* ✓ run tests (done)
|
|
11
|
+
* ✗ run lint (failed)
|
|
12
|
+
*
|
|
13
|
+
* The view is collapsible so it doesn't take over the screen while
|
|
14
|
+
* the agent is mid-stream.
|
|
15
|
+
*/
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import type { PlanStep } from '../agent/runtime.js';
|
|
18
|
+
export interface PlanViewProps {
|
|
19
|
+
steps: PlanStep[];
|
|
20
|
+
/** When true, render the full plan; when false, render only the in-progress step. */
|
|
21
|
+
expanded: boolean;
|
|
22
|
+
onToggle: () => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function PlanView({ steps, expanded, onToggle }: PlanViewProps): React.JSX.Element | null;
|
package/dist/tui/plan.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const GLYPHS = {
|
|
4
|
+
pending: '◯',
|
|
5
|
+
in_progress: '●',
|
|
6
|
+
done: '✓',
|
|
7
|
+
failed: '✗',
|
|
8
|
+
skipped: '⊘',
|
|
9
|
+
};
|
|
10
|
+
const COLORS = {
|
|
11
|
+
pending: 'gray',
|
|
12
|
+
in_progress: 'cyan',
|
|
13
|
+
done: 'green',
|
|
14
|
+
failed: 'red',
|
|
15
|
+
skipped: 'yellow',
|
|
16
|
+
};
|
|
17
|
+
export function PlanView({ steps, expanded, onToggle }) {
|
|
18
|
+
if (steps.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
if (!expanded) {
|
|
21
|
+
const current = steps.find((s) => s.status === 'in_progress') ?? steps[0];
|
|
22
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { color: COLORS[current.status], children: [GLYPHS[current.status], " "] }), _jsxs(Text, { dimColor: true, children: ["[", steps.filter((s) => s.status === 'done').length, "/", steps.length, "] ", current.title] }), _jsx(Text, { color: "gray", children: " (press /plan to expand)" })] }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\uD83D\uDCCB Plan " }), _jsxs(Text, { color: "gray", children: [" (", steps.filter((s) => s.status === 'done').length, "/", steps.length, " done)"] }), _jsx(Text, { color: "gray", children: " press /plan to collapse" })] }), steps.map((s) => (_jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: COLORS[s.status], children: [GLYPHS[s.status], " "] }), _jsx(Text, { color: s.status === 'in_progress' ? 'cyan' : undefined, bold: s.status === 'in_progress', children: s.title }), s.files && s.files.length > 0 ? (_jsxs(Text, { color: "gray", children: [" (", s.files.join(', '), ")"] })) : null] }, s.id)))] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { PlanView } from './plan.js';
|
|
5
|
+
const sampleSteps = [
|
|
6
|
+
{ id: '1', title: 'read src/foo.ts', status: 'done' },
|
|
7
|
+
{ id: '2', title: 'edit src/foo.ts', status: 'in_progress', files: ['src/foo.ts'] },
|
|
8
|
+
{ id: '3', title: 'run tests', status: 'pending' },
|
|
9
|
+
];
|
|
10
|
+
describe('PlanView', () => {
|
|
11
|
+
it('returns null when there are no steps', () => {
|
|
12
|
+
const { lastFrame } = render(_jsx(PlanView, { steps: [], expanded: true, onToggle: () => { } }));
|
|
13
|
+
expect(lastFrame()).toBe('');
|
|
14
|
+
});
|
|
15
|
+
it('renders the in-progress step when collapsed', () => {
|
|
16
|
+
const { lastFrame } = render(_jsx(PlanView, { steps: sampleSteps, expanded: false, onToggle: () => { } }));
|
|
17
|
+
const out = lastFrame();
|
|
18
|
+
expect(out).toContain('edit src/foo.ts');
|
|
19
|
+
expect(out).toContain('[1/3]');
|
|
20
|
+
expect(out).toContain('/plan');
|
|
21
|
+
});
|
|
22
|
+
it('renders all steps with status glyphs when expanded', () => {
|
|
23
|
+
const { lastFrame } = render(_jsx(PlanView, { steps: sampleSteps, expanded: true, onToggle: () => { } }));
|
|
24
|
+
const out = lastFrame();
|
|
25
|
+
expect(out).toContain('read src/foo.ts');
|
|
26
|
+
expect(out).toContain('edit src/foo.ts');
|
|
27
|
+
expect(out).toContain('run tests');
|
|
28
|
+
expect(out).toContain('1/3');
|
|
29
|
+
expect(out).toContain('Plan');
|
|
30
|
+
});
|
|
31
|
+
it('shows failure glyphs for failed steps', () => {
|
|
32
|
+
const steps = [
|
|
33
|
+
{ id: '1', title: 'do thing', status: 'failed' },
|
|
34
|
+
];
|
|
35
|
+
const { lastFrame } = render(_jsx(PlanView, { steps: steps, expanded: true, onToggle: () => { } }));
|
|
36
|
+
expect(lastFrame()).toContain('✗');
|
|
37
|
+
});
|
|
38
|
+
it('shows the files list for in-progress steps', () => {
|
|
39
|
+
const { lastFrame } = render(_jsx(PlanView, { steps: sampleSteps, expanded: true, onToggle: () => { } }));
|
|
40
|
+
expect(lastFrame()).toContain('src/foo.ts');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot tests for the full TUI App — render various states and assert
|
|
4
|
+
* the exact visible frame. This is the "what does the user actually see"
|
|
5
|
+
* ground truth.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { render } from 'ink-testing-library';
|
|
9
|
+
import { App } from './app.js';
|
|
10
|
+
const DEFAULT_PROPS = {
|
|
11
|
+
cwd: '/projects/demo',
|
|
12
|
+
onPrompt: async () => { },
|
|
13
|
+
onSlash: async () => { },
|
|
14
|
+
};
|
|
15
|
+
describe('App visual snapshot', () => {
|
|
16
|
+
it('renders header + statusline + transcript + input at idle', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
|
|
18
|
+
const frame = lastFrame();
|
|
19
|
+
// Header should be visible (uppercase KLYRO as rendered)
|
|
20
|
+
expect(frame).toContain('KLYRO');
|
|
21
|
+
expect(frame).toContain('demo'); // cwd basename
|
|
22
|
+
expect(frame).toContain('gpt-4o-mini');
|
|
23
|
+
// Status line should show
|
|
24
|
+
expect(frame).toMatch(/idle/i);
|
|
25
|
+
// Input prompt should be visible
|
|
26
|
+
expect(frame).toContain('>');
|
|
27
|
+
});
|
|
28
|
+
it('renders a transcript with assistant text', () => {
|
|
29
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
30
|
+
{ id: 'u1', kind: 'text', text: 'hello', role: 'user' },
|
|
31
|
+
{ id: 'a1', kind: 'text', text: 'hi there', role: 'assistant' },
|
|
32
|
+
] }));
|
|
33
|
+
const frame = lastFrame();
|
|
34
|
+
expect(frame).toContain('hello');
|
|
35
|
+
expect(frame).toContain('hi there');
|
|
36
|
+
});
|
|
37
|
+
it('does not render plan view when plan is empty', () => {
|
|
38
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [] }));
|
|
39
|
+
const frame = lastFrame();
|
|
40
|
+
// When plan is empty, no plan section should be visible
|
|
41
|
+
expect(frame).not.toContain('Plan');
|
|
42
|
+
});
|
|
43
|
+
it('renders plan view when plan is populated via mounted hooks', async () => {
|
|
44
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
|
|
45
|
+
const g = globalThis;
|
|
46
|
+
// Poll for hooks installed by useEffect (avoids flaky fixed timeout)
|
|
47
|
+
for (let i = 0; i < 10 && !g.__klyroAppPlan; i++)
|
|
48
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
49
|
+
g.__klyroAppPlan?.([
|
|
50
|
+
{ id: '1', title: 'Read files', status: 'done' },
|
|
51
|
+
{ id: '2', title: 'Edit code', status: 'in_progress', files: ['src/x.ts'] },
|
|
52
|
+
]);
|
|
53
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
54
|
+
const frame = lastFrame();
|
|
55
|
+
expect(frame).toContain('Read files');
|
|
56
|
+
expect(frame).toContain('Edit code');
|
|
57
|
+
});
|
|
58
|
+
it('renders file_changed inline (the colored line)', () => {
|
|
59
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
60
|
+
{ id: 'fc1', kind: 'file_changed', path: 'src/foo.ts', op: 'modified' },
|
|
61
|
+
] }));
|
|
62
|
+
const frame = lastFrame();
|
|
63
|
+
expect(frame).toContain('src/foo.ts');
|
|
64
|
+
expect(frame).toMatch(/modified|\~/);
|
|
65
|
+
});
|
|
66
|
+
it('diff transcript item renders the diff box', () => {
|
|
67
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
68
|
+
{
|
|
69
|
+
id: 'd1',
|
|
70
|
+
kind: 'diff',
|
|
71
|
+
summary: '1 file(s) changed',
|
|
72
|
+
hunks: [{
|
|
73
|
+
path: 'src/x.ts',
|
|
74
|
+
lines: [
|
|
75
|
+
{ kind: 'header', text: '@@ -1 +1 @@' },
|
|
76
|
+
{ kind: 'remove', text: 'const a = 1;' },
|
|
77
|
+
{ kind: 'add', text: 'const a = 2;' },
|
|
78
|
+
],
|
|
79
|
+
}],
|
|
80
|
+
},
|
|
81
|
+
] }));
|
|
82
|
+
const frame = lastFrame();
|
|
83
|
+
expect(frame).toContain('src/x.ts');
|
|
84
|
+
expect(frame).toContain('const a = 2;');
|
|
85
|
+
expect(frame).toContain('+ ');
|
|
86
|
+
});
|
|
87
|
+
});
|
package/dist/tui/transcript.d.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Transcript — a scrollable list of transcript entries.
|
|
3
3
|
*
|
|
4
4
|
* Each entry is a typed TranscriptItem (text delta, tool call block,
|
|
5
|
-
* tool result, policy decision, error). The component
|
|
6
|
-
* and renders it. Tool
|
|
5
|
+
* tool result, policy decision, error, file changed). The component
|
|
6
|
+
* takes a flat list and renders it. Tool blocks use a card layout
|
|
7
|
+
* (┌─ name ─┐) and show a spinner while running.
|
|
7
8
|
*
|
|
8
9
|
* State management: parent owns the list, passes a fresh array on every
|
|
9
10
|
* update. This component is pure presentational.
|
|
10
11
|
*/
|
|
11
12
|
import React from 'react';
|
|
13
|
+
import { type DiffHunk } from './diff.js';
|
|
12
14
|
export type TranscriptItem = {
|
|
13
15
|
id: string;
|
|
14
16
|
kind: 'text';
|
|
@@ -23,7 +25,8 @@ export type TranscriptItem = {
|
|
|
23
25
|
result?: string;
|
|
24
26
|
isError?: boolean;
|
|
25
27
|
latencyMs?: number;
|
|
26
|
-
|
|
28
|
+
/** When running, no result is attached yet. */
|
|
29
|
+
status: 'running' | 'done' | 'error';
|
|
27
30
|
} | {
|
|
28
31
|
id: string;
|
|
29
32
|
kind: 'policy';
|
|
@@ -34,6 +37,16 @@ export type TranscriptItem = {
|
|
|
34
37
|
id: string;
|
|
35
38
|
kind: 'error';
|
|
36
39
|
message: string;
|
|
40
|
+
} | {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: 'file_changed';
|
|
43
|
+
path: string;
|
|
44
|
+
op: 'created' | 'modified' | 'deleted';
|
|
45
|
+
} | {
|
|
46
|
+
id: string;
|
|
47
|
+
kind: 'diff';
|
|
48
|
+
hunks: DiffHunk[];
|
|
49
|
+
summary?: string;
|
|
37
50
|
};
|
|
38
51
|
export declare function Transcript({ items }: {
|
|
39
52
|
items: TranscriptItem[];
|
package/dist/tui/transcript.js
CHANGED
|
@@ -1,26 +1,72 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { DiffView } from './diff.js';
|
|
3
5
|
export function Transcript({ items }) {
|
|
4
6
|
return (_jsx(Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, children: items.length === 0 ? (_jsx(Text, { color: "gray", dimColor: true, children: "Type a prompt or /help for commands." })) : items.map((item) => (_jsx(TranscriptRow, { item: item }, item.id))) }));
|
|
5
7
|
}
|
|
8
|
+
/** Build a one-line summary of a tool call from its args JSON. */
|
|
9
|
+
function summarizeTool(name, args) {
|
|
10
|
+
if (!args || args === '{}')
|
|
11
|
+
return '';
|
|
12
|
+
let parsed = null;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(args);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return args.slice(0, 80);
|
|
18
|
+
}
|
|
19
|
+
switch (name) {
|
|
20
|
+
case 'read_file':
|
|
21
|
+
case 'write_file':
|
|
22
|
+
case 'edit_file':
|
|
23
|
+
return typeof parsed.path === 'string' ? parsed.path : '';
|
|
24
|
+
case 'shell_exec':
|
|
25
|
+
return typeof parsed.command === 'string' ? parsed.command : '';
|
|
26
|
+
case 'search_files':
|
|
27
|
+
case 'grep': {
|
|
28
|
+
const q = parsed.query ?? parsed.pattern;
|
|
29
|
+
return typeof q === 'string' ? `"${q}"` : '';
|
|
30
|
+
}
|
|
31
|
+
case 'list_directory':
|
|
32
|
+
return typeof parsed.path === 'string' ? parsed.path : '';
|
|
33
|
+
case 'git_status':
|
|
34
|
+
case 'git_diff':
|
|
35
|
+
return '';
|
|
36
|
+
default:
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
6
40
|
function TranscriptRow({ item }) {
|
|
7
41
|
if (item.kind === 'text') {
|
|
8
42
|
const color = item.role === 'user' ? 'blue' : undefined;
|
|
9
43
|
return (_jsx(Box, { marginY: 0, children: _jsxs(Text, { color: color, children: [item.role === 'user' ? '> ' : '', item.text] }) }));
|
|
10
44
|
}
|
|
11
45
|
if (item.kind === 'tool') {
|
|
12
|
-
|
|
13
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 0, paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { color: headerColor, children: ["[tool] ", item.name] }), item.latencyMs !== undefined ? (_jsxs(Text, { color: "gray", children: [" (", item.latencyMs, "ms)"] })) : null] }), item.collapsed ? (_jsxs(Text, { color: "gray", dimColor: true, children: [" (collapsed \u2014 ", item.args.length, " chars of args, ", item.result?.length ?? 0, " chars of result)"] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" args: ", truncate(item.args, 200)] }), item.result !== undefined ? (_jsxs(Text, { color: item.isError ? 'red' : 'gray', children: [" -> ", truncate(item.result, 400)] })) : null] }))] }));
|
|
46
|
+
return _jsx(ToolCard, { item: item });
|
|
14
47
|
}
|
|
15
48
|
if (item.kind === 'policy') {
|
|
16
49
|
const color = item.action === 'allow' ? 'green' : item.action === 'deny' ? 'red' : 'yellow';
|
|
17
50
|
return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "[policy] " }), _jsx(Text, { color: color, children: item.action }), _jsxs(Text, { color: "gray", children: [" ", item.name] }), item.reason ? _jsxs(Text, { color: "gray", children: [" \u2014 ", item.reason] }) : null] }));
|
|
18
51
|
}
|
|
52
|
+
if (item.kind === 'file_changed') {
|
|
53
|
+
const color = item.op === 'deleted' ? 'red' : item.op === 'created' ? 'green' : 'yellow';
|
|
54
|
+
const glyph = item.op === 'created' ? '+' : item.op === 'deleted' ? '-' : '~';
|
|
55
|
+
return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: color, children: ["[", glyph, " ", item.op, "] ", item.path] }) }));
|
|
56
|
+
}
|
|
19
57
|
if (item.kind === 'error') {
|
|
20
58
|
return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", children: ["[error] ", item.message] }) }));
|
|
21
59
|
}
|
|
60
|
+
if (item.kind === 'diff') {
|
|
61
|
+
return _jsx(DiffView, { hunks: item.hunks, summary: item.summary });
|
|
62
|
+
}
|
|
22
63
|
return null;
|
|
23
64
|
}
|
|
65
|
+
function ToolCard({ item }) {
|
|
66
|
+
const summary = summarizeTool(item.name, item.args);
|
|
67
|
+
const borderColor = item.status === 'error' ? 'red' : item.status === 'running' ? 'cyan' : 'gray';
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, marginY: 1, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "\u250C\u2500 " }), _jsx(Text, { bold: true, children: item.name }), item.status === 'running' ? (_jsxs(Text, { color: "cyan", children: [" ", _jsx(Spinner, { type: "dots" }), " running"] })) : item.status === 'error' ? (_jsx(Text, { color: "red", children: " \u2717 error" })) : (_jsxs(Text, { color: "green", children: [" \u2713 ", item.latencyMs ?? 0, "ms"] })), _jsx(Text, { color: "gray", children: " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" })] }), summary ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: truncate(summary, 200) }) })) : null, item.status !== 'running' && item.result ? (_jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsxs(Text, { color: item.isError ? 'red' : 'gray', children: [item.isError ? '✗ ' : '→ ', truncate(item.result, 400)] }) })) : null, _jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) })] }));
|
|
69
|
+
}
|
|
24
70
|
function truncate(s, max) {
|
|
25
71
|
if (s.length <= max)
|
|
26
72
|
return s;
|
|
@@ -22,50 +22,98 @@ describe('Transcript', () => {
|
|
|
22
22
|
expect(lastFrame()).toContain('world');
|
|
23
23
|
expect(lastFrame()).not.toContain('> world');
|
|
24
24
|
});
|
|
25
|
-
it('renders a tool call
|
|
25
|
+
it('renders a done tool call as a card with name, summary, and result', () => {
|
|
26
26
|
const items = [
|
|
27
27
|
{
|
|
28
28
|
id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1',
|
|
29
|
-
args: '{"path":"
|
|
29
|
+
args: '{"path":"src/foo.ts"}', result: 'content', isError: false, latencyMs: 12,
|
|
30
|
+
status: 'done',
|
|
30
31
|
},
|
|
31
32
|
];
|
|
32
33
|
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
33
34
|
const out = lastFrame();
|
|
34
|
-
expect(out).toContain('[tool]');
|
|
35
35
|
expect(out).toContain('read_file');
|
|
36
|
-
expect(out).toContain('
|
|
36
|
+
expect(out).toContain('src/foo.ts');
|
|
37
37
|
expect(out).toContain('content');
|
|
38
|
+
expect(out).toMatch(/12ms/);
|
|
38
39
|
});
|
|
39
|
-
it('
|
|
40
|
+
it('renders a running tool with a spinner and no result', () => {
|
|
40
41
|
const items = [
|
|
41
42
|
{
|
|
42
43
|
id: 't1', kind: 'tool', name: 'shell_exec', id_call: 'c1',
|
|
43
|
-
args: '
|
|
44
|
-
|
|
44
|
+
args: '{"command":"npm test"}',
|
|
45
|
+
status: 'running',
|
|
45
46
|
},
|
|
46
47
|
];
|
|
47
48
|
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
48
49
|
const out = lastFrame();
|
|
49
|
-
expect(out).toContain('
|
|
50
|
-
expect(out).toContain('
|
|
51
|
-
expect(out).
|
|
50
|
+
expect(out).toContain('shell_exec');
|
|
51
|
+
expect(out).toContain('npm test');
|
|
52
|
+
expect(out).toMatch(/running/);
|
|
53
|
+
});
|
|
54
|
+
it('renders an errored tool with a red border and error glyph', () => {
|
|
55
|
+
const items = [
|
|
56
|
+
{
|
|
57
|
+
id: 't1', kind: 'tool', name: 'shell_exec', id_call: 'c1',
|
|
58
|
+
args: '{"command":"exit 1"}', result: 'command failed', isError: true, latencyMs: 5,
|
|
59
|
+
status: 'error',
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
63
|
+
const out = lastFrame();
|
|
64
|
+
expect(out).toContain('shell_exec');
|
|
65
|
+
expect(out).toContain('command failed');
|
|
66
|
+
expect(out).toMatch(/error/);
|
|
52
67
|
});
|
|
53
|
-
it('renders policy decisions with
|
|
68
|
+
it('renders policy decisions with allow/deny colors', () => {
|
|
54
69
|
const items = [
|
|
55
|
-
{ id: 'p1', kind: 'policy', name: 'shell_exec', action: 'deny', reason: '
|
|
56
|
-
{ id: 'p2', kind: 'policy', name: 'shell_exec', action: 'allow' },
|
|
70
|
+
{ id: 'p1', kind: 'policy', name: 'shell_exec', action: 'deny', reason: 'destructive' },
|
|
57
71
|
];
|
|
58
72
|
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
59
73
|
const out = lastFrame();
|
|
60
74
|
expect(out).toContain('deny');
|
|
61
|
-
expect(out).toContain('allow');
|
|
62
75
|
expect(out).toContain('shell_exec');
|
|
76
|
+
expect(out).toContain('destructive');
|
|
77
|
+
});
|
|
78
|
+
it('renders file_changed items with op glyph', () => {
|
|
79
|
+
const items = [
|
|
80
|
+
{ id: 'f1', kind: 'file_changed', path: 'src/x.ts', op: 'created' },
|
|
81
|
+
{ id: 'f2', kind: 'file_changed', path: 'src/y.ts', op: 'deleted' },
|
|
82
|
+
];
|
|
83
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
84
|
+
const out = lastFrame();
|
|
85
|
+
expect(out).toContain('+ created');
|
|
86
|
+
expect(out).toContain('src/x.ts');
|
|
87
|
+
expect(out).toContain('- deleted');
|
|
88
|
+
expect(out).toContain('src/y.ts');
|
|
63
89
|
});
|
|
64
90
|
it('renders error items', () => {
|
|
65
91
|
const items = [
|
|
66
|
-
{ id: 'e1', kind: 'error', message: '
|
|
92
|
+
{ id: 'e1', kind: 'error', message: 'something broke' },
|
|
93
|
+
];
|
|
94
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
95
|
+
expect(lastFrame()).toContain('something broke');
|
|
96
|
+
});
|
|
97
|
+
it('summarizes a shell_exec with just the command', () => {
|
|
98
|
+
const items = [
|
|
99
|
+
{
|
|
100
|
+
id: 't1', kind: 'tool', name: 'shell_exec', id_call: 'c1',
|
|
101
|
+
args: '{"command":"ls -la"}',
|
|
102
|
+
status: 'running',
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
106
|
+
expect(lastFrame()).toContain('ls -la');
|
|
107
|
+
});
|
|
108
|
+
it('summarizes a search with the query in quotes', () => {
|
|
109
|
+
const items = [
|
|
110
|
+
{
|
|
111
|
+
id: 't1', kind: 'tool', name: 'grep', id_call: 'c1',
|
|
112
|
+
args: '{"query":"authenticate"}',
|
|
113
|
+
status: 'done', result: '12 matches',
|
|
114
|
+
},
|
|
67
115
|
];
|
|
68
116
|
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
69
|
-
expect(lastFrame()).toContain('
|
|
117
|
+
expect(lastFrame()).toContain('"authenticate"');
|
|
70
118
|
});
|
|
71
119
|
});
|