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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Tiny unified-diff parser.
3
+ *
4
+ * Handles the subset of `git diff` output that the runtime cares about:
5
+ * - `diff --git a/x b/x` headers
6
+ * - `+++ b/path` / `--- a/path` path lines
7
+ * - `@@ ... @@` hunk headers
8
+ * - `+`, `-`, ` ` (context) lines
9
+ *
10
+ * Skips:
11
+ * - `index ...` lines
12
+ * - `Binary files ... differ` lines
13
+ * - "no newline at end of file" markers
14
+ *
15
+ * Returns one DiffHunk per file. Lines are preserved in order.
16
+ */
17
+ export function parseUnifiedDiff(raw) {
18
+ const out = [];
19
+ let current = null;
20
+ for (const line of raw.split('\n')) {
21
+ if (line.startsWith('diff --git ')) {
22
+ if (current)
23
+ out.push(current);
24
+ current = null;
25
+ continue;
26
+ }
27
+ if (line.startsWith('+++ ')) {
28
+ const path = stripDiffPrefix(line.slice(4));
29
+ if (path)
30
+ current = { path, lines: [] };
31
+ continue;
32
+ }
33
+ if (line.startsWith('--- ')) {
34
+ // Path on the "from" side — we prefer the "to" path so the
35
+ // heading matches the file the user is editing.
36
+ continue;
37
+ }
38
+ if (line.startsWith('@@')) {
39
+ if (current)
40
+ current.lines.push({ kind: 'header', text: line });
41
+ continue;
42
+ }
43
+ if (!current)
44
+ continue;
45
+ if (line.startsWith('+')) {
46
+ current.lines.push({ kind: 'add', text: line.slice(1) });
47
+ continue;
48
+ }
49
+ if (line.startsWith('-')) {
50
+ current.lines.push({ kind: 'remove', text: line.slice(1) });
51
+ continue;
52
+ }
53
+ if (line.startsWith(' ')) {
54
+ current.lines.push({ kind: 'context', text: line.slice(1) });
55
+ continue;
56
+ }
57
+ if (line.startsWith('index ') || line.startsWith('Binary files'))
58
+ continue;
59
+ if (line === '\')
60
+ continue;
61
+ // Unknown line — keep as context so we don't lose info.
62
+ current.lines.push({ kind: 'context', text: line });
63
+ }
64
+ if (current)
65
+ out.push(current);
66
+ return out;
67
+ }
68
+ function stripDiffPrefix(s) {
69
+ // "b/src/foo.ts" → "src/foo.ts"; "a/src/foo.ts" → "src/foo.ts"
70
+ if (s.startsWith('b/') || s.startsWith('a/'))
71
+ return s.slice(2);
72
+ return s;
73
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * DiffView — a scrollable unified diff display.
3
+ *
4
+ * Data model: a list of `DiffHunk` (one per file). Each hunk has a path
5
+ * and a list of `DiffLine` with kind (add/remove/context/header).
6
+ *
7
+ * src/auth/service.ts
8
+ * - const old = 1;
9
+ * + const old = 2;
10
+ * const same = true;
11
+ * + // new line
12
+ *
13
+ * Lines are truncated to 200 chars and color-coded. The view returns
14
+ * null when there's no diff to show.
15
+ */
16
+ import React from 'react';
17
+ export type DiffLineKind = 'add' | 'remove' | 'context' | 'header';
18
+ export interface DiffLine {
19
+ kind: DiffLineKind;
20
+ text: string;
21
+ }
22
+ export interface DiffHunk {
23
+ path: string;
24
+ lines: DiffLine[];
25
+ }
26
+ export interface DiffViewProps {
27
+ hunks: DiffHunk[];
28
+ /** Optional: total file count summary. */
29
+ summary?: string;
30
+ }
31
+ export declare function DiffView({ hunks, summary }: DiffViewProps): React.JSX.Element | null;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const LINE_COLORS = {
4
+ add: 'green',
5
+ remove: 'red',
6
+ context: 'gray',
7
+ header: 'cyan',
8
+ };
9
+ const GLYPHS = {
10
+ add: '+',
11
+ remove: '-',
12
+ context: ' ',
13
+ header: '@',
14
+ };
15
+ function truncate(s, max) {
16
+ if (s.length <= max)
17
+ return s;
18
+ return s.slice(0, max) + '…';
19
+ }
20
+ export function DiffView({ hunks, summary }) {
21
+ if (hunks.length === 0) {
22
+ return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, marginY: 1, children: _jsx(Text, { color: "gray", children: "(no working-tree changes)" }) }));
23
+ }
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1, children: [summary ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\uD83D\uDCDD Diff " }), _jsxs(Text, { color: "gray", children: [" ", summary] })] })) : null, hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u2500\u2500 ", h.path, " "] }), _jsxs(Text, { color: "gray", children: ["(", h.lines.filter((l) => l.kind === 'add').length, "+ /", ' ', h.lines.filter((l) => l.kind === 'remove').length, "-)"] })] }), h.lines.map((l, j) => (_jsxs(Box, { children: [_jsxs(Text, { color: LINE_COLORS[l.kind], children: [GLYPHS[l.kind], " "] }), _jsx(Text, { color: LINE_COLORS[l.kind], children: truncate(l.text, 200) })] }, j)))] }, `${h.path}-${i}`)))] }));
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import { DiffView } from './diff.js';
5
+ import { parseUnifiedDiff } from './diff-parser.js';
6
+ const SAMPLE = `diff --git a/src/foo.ts b/src/foo.ts
7
+ index 1234..5678 100644
8
+ --- a/src/foo.ts
9
+ +++ b/src/foo.ts
10
+ @@ -1,3 +1,4 @@
11
+ const a = 1;
12
+ -const b = 2;
13
+ +const b = 3;
14
+ +const c = 4;
15
+ export {};
16
+ diff --git a/src/bar.ts b/src/bar.ts
17
+ index aaaa..bbbb 100644
18
+ --- a/src/bar.ts
19
+ +++ b/src/bar.ts
20
+ @@ -1,2 +1,2 @@
21
+ -const x = 'old';
22
+ +const x = 'new';
23
+ `;
24
+ describe('parseUnifiedDiff', () => {
25
+ it('returns one hunk per file', () => {
26
+ const hunks = parseUnifiedDiff(SAMPLE);
27
+ expect(hunks).toHaveLength(2);
28
+ expect(hunks[0].path).toBe('src/foo.ts');
29
+ expect(hunks[1].path).toBe('src/bar.ts');
30
+ });
31
+ it('classifies add/remove/context/header lines', () => {
32
+ const hunks = parseUnifiedDiff(SAMPLE);
33
+ const lines = hunks[0].lines;
34
+ expect(lines[0].kind).toBe('header');
35
+ expect(lines[1].kind).toBe('context');
36
+ expect(lines[2].kind).toBe('remove');
37
+ expect(lines[3].kind).toBe('add');
38
+ expect(lines[4].kind).toBe('add');
39
+ });
40
+ it('skips index/binary/no-newline markers', () => {
41
+ const hunks = parseUnifiedDiff(SAMPLE);
42
+ for (const h of hunks) {
43
+ for (const l of h.lines) {
44
+ expect(l.text).not.toMatch(/^index /);
45
+ expect(l.text).not.toMatch(/Binary files/);
46
+ expect(l.text).not.toMatch(/No newline at end/);
47
+ }
48
+ }
49
+ });
50
+ it('returns empty array for empty input', () => {
51
+ expect(parseUnifiedDiff('')).toEqual([]);
52
+ });
53
+ });
54
+ describe('DiffView', () => {
55
+ it('shows an empty-state when hunks is empty', () => {
56
+ const { lastFrame } = render(_jsx(DiffView, { hunks: [] }));
57
+ expect(lastFrame()).toContain('no working-tree changes');
58
+ });
59
+ it('renders a file header and line glyphs', () => {
60
+ const hunks = [
61
+ {
62
+ path: 'src/foo.ts',
63
+ lines: [
64
+ { kind: 'header', text: '@@ -1 +1 @@' },
65
+ { kind: 'context', text: 'const a = 1;' },
66
+ { kind: 'remove', text: 'const b = 2;' },
67
+ { kind: 'add', text: 'const b = 3;' },
68
+ ],
69
+ },
70
+ ];
71
+ const { lastFrame } = render(_jsx(DiffView, { hunks: hunks, summary: "1 file changed" }));
72
+ const out = lastFrame();
73
+ expect(out).toContain('src/foo.ts');
74
+ expect(out).toContain('+ const b = 3;');
75
+ expect(out).toContain('- const b = 2;');
76
+ expect(out).toContain('1 file changed');
77
+ });
78
+ it('truncates long lines', () => {
79
+ const long = 'x'.repeat(500);
80
+ const hunks = [
81
+ { path: 'f.ts', lines: [{ kind: 'add', text: long }] },
82
+ ];
83
+ const { lastFrame } = render(_jsx(DiffView, { hunks: hunks }));
84
+ expect(lastFrame()).toContain('…');
85
+ });
86
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Header — top-of-screen chrome: app name, cwd, model, step counter.
3
+ *
4
+ * Renders a single line with two padded cells separated by a vertical bar.
5
+ * Pure presentational; takes all data as props.
6
+ */
7
+ import React from 'react';
8
+ export interface HeaderProps {
9
+ /** Working directory, displayed abbreviated (last 2 path segments) if long. */
10
+ cwd: string;
11
+ /** Model id, e.g. "claude-sonnet" or "gpt-4o". */
12
+ model: string;
13
+ /** Current step number (0 when not started). */
14
+ step: number;
15
+ /** Max steps for this run (used to render "step / max"). */
16
+ maxSteps: number;
17
+ }
18
+ /** Abbreviate a path to the last two segments so long paths don't overflow. */
19
+ export declare function abbrevPath(p: string, maxChars?: number): string;
20
+ export declare function Header({ cwd, model, step, maxSteps }: HeaderProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /** Abbreviate a path to the last two segments so long paths don't overflow. */
4
+ export function abbrevPath(p, maxChars = 60) {
5
+ if (p.length <= maxChars)
6
+ return p;
7
+ // Detect the original separator style and rejoin with it.
8
+ const sep = p.includes('\\') ? '\\' : '/';
9
+ const parts = p.split(/[\\/]/).filter(Boolean);
10
+ if (parts.length <= 2)
11
+ return p;
12
+ return '…' + sep + parts.slice(-2).join(sep);
13
+ }
14
+ export function Header({ cwd, model, step, maxSteps }) {
15
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "KLYRO" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "gray", children: abbrevPath(cwd) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: model }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: step >= maxSteps ? 'red' : 'gray', children: ["step ", step, "/", maxSteps] })] })] }));
16
+ }
@@ -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;
@@ -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
+ });
@@ -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 takes a flat list
6
- * and renders it. Tool call blocks are collapsible via a `collapsed` flag.
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
- collapsed?: boolean;
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[];
@@ -1,26 +1,72 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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
- const headerColor = item.isError ? 'red' : 'yellow';
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 block with header and result', () => {
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":"x"}', result: 'content', isError: false, latencyMs: 12,
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('(12ms)');
36
+ expect(out).toContain('src/foo.ts');
37
37
  expect(out).toContain('content');
38
+ expect(out).toMatch(/12ms/);
38
39
  });
39
- it('collapses long tool args/results when collapsed=true', () => {
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: 'x'.repeat(500), result: 'y'.repeat(500), isError: false, latencyMs: 5,
44
- collapsed: true,
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('collapsed');
50
- expect(out).toContain('500');
51
- expect(out).not.toContain('x'.repeat(50));
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 the right color cue', () => {
68
+ it('renders policy decisions with allow/deny colors', () => {
54
69
  const items = [
55
- { id: 'p1', kind: 'policy', name: 'shell_exec', action: 'deny', reason: 'r' },
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: 'oops' },
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('[error] oops');
117
+ expect(lastFrame()).toContain('"authenticate"');
70
118
  });
71
119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",