klyro 0.1.1
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/agent/anthropic-adapter.d.ts +75 -0
- package/dist/agent/anthropic-adapter.js +297 -0
- package/dist/agent/message.d.ts +40 -0
- package/dist/agent/message.js +21 -0
- package/dist/agent/observation.d.ts +47 -0
- package/dist/agent/observation.js +53 -0
- package/dist/agent/provider-adapter.d.ts +101 -0
- package/dist/agent/provider-adapter.js +254 -0
- package/dist/agent/registry.d.ts +42 -0
- package/dist/agent/registry.js +86 -0
- package/dist/agent/retry.d.ts +34 -0
- package/dist/agent/retry.js +91 -0
- package/dist/agent/runtime.d.ts +130 -0
- package/dist/agent/runtime.js +221 -0
- package/dist/agent/worker-spawner.d.ts +41 -0
- package/dist/agent/worker-spawner.js +76 -0
- package/dist/chat.d.ts +39 -0
- package/dist/chat.js +235 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli/eval.d.ts +75 -0
- package/dist/cli/eval.js +190 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +194 -0
- package/dist/cli/run.d.ts +67 -0
- package/dist/cli/run.js +198 -0
- package/dist/cli/slash/parser.d.ts +39 -0
- package/dist/cli/slash/parser.js +47 -0
- package/dist/context/level6.d.ts +45 -0
- package/dist/context/level6.js +231 -0
- package/dist/context/level7.d.ts +84 -0
- package/dist/context/level7.js +156 -0
- package/dist/context/project-map.d.ts +48 -0
- package/dist/context/project-map.js +438 -0
- package/dist/context/repo-map.d.ts +30 -0
- package/dist/context/repo-map.js +168 -0
- package/dist/context/selector.d.ts +22 -0
- package/dist/context/selector.js +37 -0
- package/dist/context/snippets.d.ts +17 -0
- package/dist/context/snippets.js +15 -0
- package/dist/context/tokenizer.d.ts +49 -0
- package/dist/context/tokenizer.js +97 -0
- package/dist/eval/harness.d.ts +48 -0
- package/dist/eval/harness.js +149 -0
- package/dist/eval/tasks.d.ts +6 -0
- package/dist/eval/tasks.js +98 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/audit.d.ts +75 -0
- package/dist/persistence/audit.js +15 -0
- package/dist/persistence/store.d.ts +67 -0
- package/dist/persistence/store.js +106 -0
- package/dist/policy/approval.d.ts +35 -0
- package/dist/policy/approval.js +54 -0
- package/dist/policy/engine.d.ts +76 -0
- package/dist/policy/engine.js +163 -0
- package/dist/policy/path-guard.d.ts +30 -0
- package/dist/policy/path-guard.js +100 -0
- package/dist/policy/secret-redactor.d.ts +19 -0
- package/dist/policy/secret-redactor.js +52 -0
- package/dist/providers.d.ts +23 -0
- package/dist/providers.js +83 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +170 -0
- package/dist/repl.js.map +1 -0
- package/dist/tools/fs/edit-file.d.ts +48 -0
- package/dist/tools/fs/edit-file.js +84 -0
- package/dist/tools/fs/list-dir.d.ts +40 -0
- package/dist/tools/fs/list-dir.js +72 -0
- package/dist/tools/fs/read-file.d.ts +66 -0
- package/dist/tools/fs/read-file.js +75 -0
- package/dist/tools/fs/write-file.d.ts +24 -0
- package/dist/tools/fs/write-file.js +48 -0
- package/dist/tools/git/git-diff.d.ts +35 -0
- package/dist/tools/git/git-diff.js +68 -0
- package/dist/tools/git/git-status.d.ts +29 -0
- package/dist/tools/git/git-status.js +57 -0
- package/dist/tools/normalize.d.ts +31 -0
- package/dist/tools/normalize.js +88 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +87 -0
- package/dist/tools/schema.d.ts +29 -0
- package/dist/tools/schema.js +183 -0
- package/dist/tools/search/dependencies.d.ts +44 -0
- package/dist/tools/search/dependencies.js +188 -0
- package/dist/tools/search/glob.d.ts +26 -0
- package/dist/tools/search/glob.js +88 -0
- package/dist/tools/search/grep.d.ts +52 -0
- package/dist/tools/search/grep.js +125 -0
- package/dist/tools/search/recent-files.d.ts +33 -0
- package/dist/tools/search/recent-files.js +100 -0
- package/dist/tools/search/search-files.d.ts +39 -0
- package/dist/tools/search/search-files.js +110 -0
- package/dist/tools/shell/shell-exec.d.ts +60 -0
- package/dist/tools/shell/shell-exec.js +133 -0
- package/dist/tools/types.d.ts +54 -0
- package/dist/tools/types.js +15 -0
- package/dist/tools/verify/run-verify.d.ts +60 -0
- package/dist/tools/verify/run-verify.js +115 -0
- package/dist/tui/app.d.ts +27 -0
- package/dist/tui/app.js +79 -0
- package/dist/tui/app.test.d.ts +1 -0
- package/dist/tui/app.test.js +77 -0
- package/dist/tui/status.d.ts +21 -0
- package/dist/tui/status.js +13 -0
- package/dist/tui/status.test.d.ts +1 -0
- package/dist/tui/status.test.js +39 -0
- package/dist/tui/transcript.d.ts +40 -0
- package/dist/tui/transcript.js +28 -0
- package/dist/tui/transcript.test.d.ts +1 -0
- package/dist/tui/transcript.test.js +71 -0
- package/dist/verification/detect.d.ts +29 -0
- package/dist/verification/detect.js +85 -0
- package/dist/verification/engine.d.ts +25 -0
- package/dist/verification/engine.js +57 -0
- package/package.json +58 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool surface — the contract between the agent and the outside world.
|
|
3
|
+
*
|
|
4
|
+
* Every action the agent can take on a repo is a Tool. Tools:
|
|
5
|
+
* 1. Validate input via Zod
|
|
6
|
+
* 2. Produce a JSON Schema for the model
|
|
7
|
+
* 3. Execute deterministically (modulo shell)
|
|
8
|
+
* 4. Normalize errors into { code, message, details? }
|
|
9
|
+
*/
|
|
10
|
+
import type { z } from 'zod';
|
|
11
|
+
/** Standard tool result — either a typed value or a structured error. */
|
|
12
|
+
export type ToolResult<T> = {
|
|
13
|
+
ok: true;
|
|
14
|
+
value: T;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
error: {
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
details?: unknown;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Context handed to every tool execution. Tools MUST treat this as immutable
|
|
25
|
+
* for the duration of execution.
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolContext {
|
|
28
|
+
/** Working directory. All relative paths resolve here. */
|
|
29
|
+
cwd: string;
|
|
30
|
+
/** Environment variables (read-only snapshot). */
|
|
31
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
/** Optional abort signal — tools must check periodically for long ops. */
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/** True when running headless (CI, pipe). Tools should avoid prompts. */
|
|
35
|
+
nonInteractive?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A typed tool definition. TInput is the validated input shape; TOutput is
|
|
39
|
+
* what the tool returns on success.
|
|
40
|
+
*/
|
|
41
|
+
export interface Tool<TInput, TOutput> {
|
|
42
|
+
/** Stable, machine-readable name. Lowercase + underscores. */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Human-readable description — shown to the model. */
|
|
45
|
+
description: string;
|
|
46
|
+
/** Zod schema for runtime validation. */
|
|
47
|
+
inputSchema: z.ZodType<TInput>;
|
|
48
|
+
/** Execute the tool. Must never throw — return a ToolResult instead. */
|
|
49
|
+
execute(input: TInput, ctx: ToolContext): Promise<ToolResult<TOutput>>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Helper to construct a Tool without restating generics.
|
|
53
|
+
*/
|
|
54
|
+
export declare function defineTool<TInput, TOutput>(tool: Tool<TInput, TOutput>): Tool<TInput, TOutput>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool surface — the contract between the agent and the outside world.
|
|
3
|
+
*
|
|
4
|
+
* Every action the agent can take on a repo is a Tool. Tools:
|
|
5
|
+
* 1. Validate input via Zod
|
|
6
|
+
* 2. Produce a JSON Schema for the model
|
|
7
|
+
* 3. Execute deterministically (modulo shell)
|
|
8
|
+
* 4. Normalize errors into { code, message, details? }
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Helper to construct a Tool without restating generics.
|
|
12
|
+
*/
|
|
13
|
+
export function defineTool(tool) {
|
|
14
|
+
return tool;
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_verify — the verification engine's tool. Runs a user-supplied command
|
|
3
|
+
* (e.g. `npm test`, `tsc --noEmit`) and returns the structured result.
|
|
4
|
+
*
|
|
5
|
+
* Differentiated from `shell_exec` so that downstream code (the verification
|
|
6
|
+
* engine) can pick a more permissive policy for the verify command: it can
|
|
7
|
+
* run `npm test`, which shell_exec might want approval for in some configs.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
declare const InputSchema: z.ZodObject<{
|
|
11
|
+
command: z.ZodString;
|
|
12
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
13
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
export interface VerifyOutput {
|
|
16
|
+
command: string;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
signal: NodeJS.Signals | null;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare const runVerifyTool: import("../types.js").Tool<{
|
|
27
|
+
command: string;
|
|
28
|
+
cwd?: string | undefined;
|
|
29
|
+
timeoutMs?: number | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
readonly ok: false;
|
|
32
|
+
readonly error: {
|
|
33
|
+
readonly code: "TIMEOUT";
|
|
34
|
+
readonly message: `verify command timed out after ${number}ms`;
|
|
35
|
+
readonly details: {
|
|
36
|
+
readonly command: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
command?: undefined;
|
|
40
|
+
exitCode?: undefined;
|
|
41
|
+
signal?: undefined;
|
|
42
|
+
stdout?: undefined;
|
|
43
|
+
stderr?: undefined;
|
|
44
|
+
durationMs?: undefined;
|
|
45
|
+
timedOut?: undefined;
|
|
46
|
+
truncated?: undefined;
|
|
47
|
+
} | {
|
|
48
|
+
command: string;
|
|
49
|
+
exitCode: number | null;
|
|
50
|
+
signal: NodeJS.Signals | null;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
timedOut: false;
|
|
55
|
+
ok: boolean;
|
|
56
|
+
truncated: false;
|
|
57
|
+
readonly error?: undefined;
|
|
58
|
+
}>;
|
|
59
|
+
export type RunVerifyInput = z.infer<typeof InputSchema>;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_verify — the verification engine's tool. Runs a user-supplied command
|
|
3
|
+
* (e.g. `npm test`, `tsc --noEmit`) and returns the structured result.
|
|
4
|
+
*
|
|
5
|
+
* Differentiated from `shell_exec` so that downstream code (the verification
|
|
6
|
+
* engine) can pick a more permissive policy for the verify command: it can
|
|
7
|
+
* run `npm test`, which shell_exec might want approval for in some configs.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { defineTool } from '../types.js';
|
|
12
|
+
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
13
|
+
const InputSchema = z.object({
|
|
14
|
+
command: z.string().min(1).describe('Verification command, e.g. "npm test" or "tsc --noEmit"'),
|
|
15
|
+
cwd: z.string().optional(),
|
|
16
|
+
timeoutMs: z.number().int().min(1).max(600_000).optional().describe('Default 300000 (5 min)'),
|
|
17
|
+
});
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
19
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
20
|
+
export const runVerifyTool = defineTool({
|
|
21
|
+
name: 'run_verify',
|
|
22
|
+
description: 'Run a verification command (e.g. tests, typecheck) and return exit code, stdout, stderr. Default timeout 5 minutes. Output is truncated at 256 KiB. Returns ok=true iff exit code is 0.',
|
|
23
|
+
inputSchema: InputSchema,
|
|
24
|
+
execute: async (input, ctx) => {
|
|
25
|
+
return safe(async () => {
|
|
26
|
+
const cwd = input.cwd ?? ctx.cwd;
|
|
27
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
28
|
+
const env = process.env;
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
const child = spawn(input.command, {
|
|
31
|
+
cwd,
|
|
32
|
+
env,
|
|
33
|
+
shell: true,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
signal: ctx.signal,
|
|
37
|
+
});
|
|
38
|
+
const stdout = [];
|
|
39
|
+
const stderr = [];
|
|
40
|
+
let totalOut = 0;
|
|
41
|
+
let totalErr = 0;
|
|
42
|
+
let truncated = false;
|
|
43
|
+
child.stdout?.on('data', (b) => {
|
|
44
|
+
if (totalOut + b.length > MAX_OUTPUT_BYTES) {
|
|
45
|
+
truncated = true;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
stdout.push(b);
|
|
49
|
+
totalOut += b.length;
|
|
50
|
+
});
|
|
51
|
+
child.stderr?.on('data', (b) => {
|
|
52
|
+
if (totalErr + b.length > MAX_OUTPUT_BYTES) {
|
|
53
|
+
truncated = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
stderr.push(b);
|
|
57
|
+
totalErr += b.length;
|
|
58
|
+
});
|
|
59
|
+
// Resolve on the FIRST of exit/timer/error — see shell-exec.ts for why
|
|
60
|
+
// awaiting `close` alone deadlocks on Windows with a pipe-holding grandchild.
|
|
61
|
+
const outcome = await new Promise((resolve) => {
|
|
62
|
+
let done = false;
|
|
63
|
+
const finish = (o) => {
|
|
64
|
+
if (done)
|
|
65
|
+
return;
|
|
66
|
+
done = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve(o);
|
|
69
|
+
};
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
// Windows grandchild cleanup — see shell-exec.ts for why.
|
|
72
|
+
if (process.platform === 'win32') {
|
|
73
|
+
try {
|
|
74
|
+
require('node:child_process').spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
|
|
75
|
+
}
|
|
76
|
+
catch { /* already gone */ }
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
try {
|
|
80
|
+
child.kill('SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
catch { /* noop */ }
|
|
83
|
+
}
|
|
84
|
+
finish({ code: null, signal: 'SIGKILL', timedOut: true });
|
|
85
|
+
}, timeoutMs);
|
|
86
|
+
child.on('exit', (code, signal) => finish({ code, signal, timedOut: false }));
|
|
87
|
+
child.on('error', () => finish({ code: null, signal: null, timedOut: false }));
|
|
88
|
+
});
|
|
89
|
+
const { code: exitCode, signal, timedOut } = outcome;
|
|
90
|
+
const ok = exitCode === 0 && !timedOut;
|
|
91
|
+
if (!ok) {
|
|
92
|
+
// Tool still succeeds — non-zero exit is a verify failure, not a tool
|
|
93
|
+
// failure. The verification engine consumes this output. We *do*
|
|
94
|
+
// escalate timeout to TIMEOUT, since that's a hard error mode.
|
|
95
|
+
if (timedOut) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: { code: TOOL_ERROR_CODES.TIMEOUT, message: `verify command timed out after ${timeoutMs}ms`, details: { command: input.command } },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
command: input.command,
|
|
104
|
+
exitCode,
|
|
105
|
+
signal,
|
|
106
|
+
stdout: Buffer.concat(stdout).toString('utf-8'),
|
|
107
|
+
stderr: Buffer.concat(stderr).toString('utf-8'),
|
|
108
|
+
durationMs: Date.now() - start,
|
|
109
|
+
timedOut,
|
|
110
|
+
ok,
|
|
111
|
+
truncated,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink app root — status line + scrollable transcript + input box.
|
|
3
|
+
*
|
|
4
|
+
* Owns the session state machine:
|
|
5
|
+
* - transcript: array of TranscriptItem (rendered by <Transcript/>)
|
|
6
|
+
* - status: snapshot for the <StatusLine/>
|
|
7
|
+
* - input: the current input-box buffer
|
|
8
|
+
*
|
|
9
|
+
* The actual agent loop runs externally; the app just listens for
|
|
10
|
+
* RuntimeEvents via the onEvent callback wired by cli/repl.ts and
|
|
11
|
+
* translates them into transcript/status updates.
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { type StatusSnapshot } from './status.js';
|
|
15
|
+
import { type TranscriptItem } from './transcript.js';
|
|
16
|
+
export interface AppProps {
|
|
17
|
+
initialModel: string;
|
|
18
|
+
maxSteps: number;
|
|
19
|
+
/** Called when the user submits a non-slash prompt. */
|
|
20
|
+
onPrompt: (text: string) => void | Promise<void>;
|
|
21
|
+
/** Called when the user types a slash command. */
|
|
22
|
+
onSlash: (cmd: import('../cli/slash/parser.js').SlashCommand) => void | Promise<void>;
|
|
23
|
+
/** Initial state (e.g. when resuming a session). */
|
|
24
|
+
initialTranscript?: TranscriptItem[];
|
|
25
|
+
initialStatus?: Partial<StatusSnapshot>;
|
|
26
|
+
}
|
|
27
|
+
export declare function App(props: AppProps): React.JSX.Element;
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Ink app root — status line + scrollable transcript + input box.
|
|
4
|
+
*
|
|
5
|
+
* Owns the session state machine:
|
|
6
|
+
* - transcript: array of TranscriptItem (rendered by <Transcript/>)
|
|
7
|
+
* - status: snapshot for the <StatusLine/>
|
|
8
|
+
* - input: the current input-box buffer
|
|
9
|
+
*
|
|
10
|
+
* The actual agent loop runs externally; the app just listens for
|
|
11
|
+
* RuntimeEvents via the onEvent callback wired by cli/repl.ts and
|
|
12
|
+
* translates them into transcript/status updates.
|
|
13
|
+
*/
|
|
14
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
15
|
+
import { Box, Text, useInput } from 'ink';
|
|
16
|
+
import { StatusLine } from './status.js';
|
|
17
|
+
import { Transcript } from './transcript.js';
|
|
18
|
+
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
19
|
+
let _itemCounter = 0;
|
|
20
|
+
function nextId(prefix) {
|
|
21
|
+
_itemCounter += 1;
|
|
22
|
+
return `${prefix}-${_itemCounter}`;
|
|
23
|
+
}
|
|
24
|
+
export function App(props) {
|
|
25
|
+
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
26
|
+
const [input, setInput] = useState('');
|
|
27
|
+
const [status, setStatus] = useState({
|
|
28
|
+
model: props.initialModel,
|
|
29
|
+
step: 0,
|
|
30
|
+
maxSteps: props.maxSteps,
|
|
31
|
+
usageInput: 0,
|
|
32
|
+
usageOutput: 0,
|
|
33
|
+
repairs: 0,
|
|
34
|
+
status: 'idle',
|
|
35
|
+
...props.initialStatus,
|
|
36
|
+
});
|
|
37
|
+
const append = useCallback((item) => {
|
|
38
|
+
setTranscript((prev) => [...prev, item]);
|
|
39
|
+
}, []);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
globalThis.__klyroAppAppend = append;
|
|
42
|
+
globalThis.__klyroAppStatus = (s) => setStatus((prev) => ({ ...prev, ...s }));
|
|
43
|
+
return () => {
|
|
44
|
+
delete globalThis.__klyroAppAppend;
|
|
45
|
+
delete globalThis.__klyroAppStatus;
|
|
46
|
+
};
|
|
47
|
+
}, [append]);
|
|
48
|
+
useInput((inputStr, key) => {
|
|
49
|
+
if (status.status === 'running')
|
|
50
|
+
return;
|
|
51
|
+
if (key.return) {
|
|
52
|
+
const value = input.trim();
|
|
53
|
+
setInput('');
|
|
54
|
+
if (!value)
|
|
55
|
+
return;
|
|
56
|
+
append({ id: nextId('text'), kind: 'text', text: value, role: 'user' });
|
|
57
|
+
const cmd = parseSlash(value);
|
|
58
|
+
if (cmd.kind === 'prompt') {
|
|
59
|
+
void props.onPrompt(cmd.text);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
void props.onSlash(cmd);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (key.backspace || key.delete) {
|
|
67
|
+
setInput((v) => v.slice(0, -1));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (key.ctrl && inputStr === 'c') {
|
|
71
|
+
void props.onSlash({ kind: 'quit' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!key.ctrl && !key.meta) {
|
|
75
|
+
setInput((v) => v + inputStr);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
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" })] })] }));
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { App } from './app.js';
|
|
5
|
+
describe('App', () => {
|
|
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 () => { } }));
|
|
8
|
+
const out = lastFrame();
|
|
9
|
+
expect(out).toContain('mock');
|
|
10
|
+
expect(out).toMatch(/Type a prompt/);
|
|
11
|
+
});
|
|
12
|
+
it('renders initial transcript items', () => {
|
|
13
|
+
const items = [
|
|
14
|
+
{ id: '1', kind: 'text', text: 'seed', role: 'user' },
|
|
15
|
+
];
|
|
16
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { }, initialTranscript: items }));
|
|
17
|
+
expect(lastFrame()).toContain('> seed');
|
|
18
|
+
});
|
|
19
|
+
it('honors initialStatus overrides', () => {
|
|
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 }));
|
|
22
|
+
const out = lastFrame();
|
|
23
|
+
expect(out).toContain('5');
|
|
24
|
+
expect(out).toContain('10');
|
|
25
|
+
expect(out).toContain('3');
|
|
26
|
+
expect(out).toContain('running');
|
|
27
|
+
});
|
|
28
|
+
it('installs and tears down the global bridge hooks', () => {
|
|
29
|
+
const g = globalThis;
|
|
30
|
+
const { unmount } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: async () => { } }));
|
|
31
|
+
expect(g.__klyroAppAppend).toBeTypeOf('function');
|
|
32
|
+
expect(g.__klyroAppStatus).toBeTypeOf('function');
|
|
33
|
+
unmount();
|
|
34
|
+
expect(g.__klyroAppAppend).toBeUndefined();
|
|
35
|
+
expect(g.__klyroAppStatus).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
it('submits a non-slash prompt via onPrompt', async () => {
|
|
38
|
+
const onPrompt = vi.fn(async () => { });
|
|
39
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: onPrompt, onSlash: async () => { } }));
|
|
40
|
+
stdin.write('hello world');
|
|
41
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
42
|
+
stdin.write('\x0d');
|
|
43
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
44
|
+
expect(onPrompt).toHaveBeenCalledWith('hello world');
|
|
45
|
+
});
|
|
46
|
+
it('routes a slash command to onSlash', async () => {
|
|
47
|
+
const onSlash = vi.fn(async () => { });
|
|
48
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: onSlash }));
|
|
49
|
+
stdin.write('/help');
|
|
50
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
51
|
+
stdin.write('\x0d');
|
|
52
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
53
|
+
expect(onSlash).toHaveBeenCalled();
|
|
54
|
+
const call = onSlash.mock.calls[0]?.[0];
|
|
55
|
+
expect(call?.kind).toBe('help');
|
|
56
|
+
});
|
|
57
|
+
it('ignores Enter while status is running', async () => {
|
|
58
|
+
const onPrompt = vi.fn(async () => { });
|
|
59
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: onPrompt, onSlash: async () => { }, initialStatus: { status: 'running' } }));
|
|
60
|
+
stdin.write('hello');
|
|
61
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
62
|
+
stdin.write('\x0d');
|
|
63
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
64
|
+
expect(onPrompt).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('routes /quit to onSlash as a quit command', async () => {
|
|
67
|
+
const onSlash = vi.fn(async () => { });
|
|
68
|
+
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, onPrompt: async () => { }, onSlash: onSlash }));
|
|
69
|
+
stdin.write('/quit');
|
|
70
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
71
|
+
stdin.write('\x0d');
|
|
72
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
73
|
+
expect(onSlash).toHaveBeenCalled();
|
|
74
|
+
const call = onSlash.mock.calls[0]?.[0];
|
|
75
|
+
expect(call?.kind).toBe('quit');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status line — model name, step counter, usage, repair counter.
|
|
3
|
+
*
|
|
4
|
+
* Pure presentation: takes a snapshot of session state, renders a single
|
|
5
|
+
* Ink <Box> with the relevant fields. State changes are pushed from
|
|
6
|
+
* app.tsx via React props.
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react';
|
|
9
|
+
export interface StatusSnapshot {
|
|
10
|
+
model: string;
|
|
11
|
+
step: number;
|
|
12
|
+
maxSteps: number;
|
|
13
|
+
usageInput: number;
|
|
14
|
+
usageOutput: number;
|
|
15
|
+
repairs: number;
|
|
16
|
+
status: 'idle' | 'running' | 'done' | 'error' | 'aborted';
|
|
17
|
+
errorMessage?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function StatusLine({ snapshot }: {
|
|
20
|
+
snapshot: StatusSnapshot;
|
|
21
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
export function StatusLine({ snapshot }) {
|
|
4
|
+
const { model, step, maxSteps, usageInput, usageOutput, repairs, status } = snapshot;
|
|
5
|
+
const statusColor = status === 'running' ? 'cyan' :
|
|
6
|
+
status === 'done' ? 'green' :
|
|
7
|
+
status === 'error' ? 'red' :
|
|
8
|
+
status === 'aborted' ? 'yellow' :
|
|
9
|
+
'gray';
|
|
10
|
+
const usageKbIn = (usageInput / 1024).toFixed(1);
|
|
11
|
+
const usageKbOut = (usageOutput / 1024).toFixed(1);
|
|
12
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: "gray", children: "model " }), _jsx(Text, { bold: true, children: model }), _jsx(Text, { color: "gray", children: " step " }), _jsx(Text, { bold: true, children: step }), _jsxs(Text, { color: "gray", children: ["/", maxSteps] }), _jsx(Text, { color: "gray", children: " repairs " }), _jsx(Text, { bold: true, children: repairs })] }), _jsxs(Text, { children: [_jsx(Text, { color: "gray", children: "tokens " }), _jsxs(Text, { color: "cyan", children: [usageKbIn, "K"] }), _jsx(Text, { color: "gray", children: " in / " }), _jsxs(Text, { color: "cyan", children: [usageKbOut, "K"] }), _jsx(Text, { color: "gray", children: " out" })] }), _jsx(Text, { children: _jsxs(Text, { color: statusColor, children: ["\u25CF ", status] }) })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { StatusLine } from './status.js';
|
|
5
|
+
describe('StatusLine', () => {
|
|
6
|
+
it('renders model, step, repairs, and token usage', () => {
|
|
7
|
+
const snapshot = {
|
|
8
|
+
model: 'gpt-4o-mini',
|
|
9
|
+
step: 3,
|
|
10
|
+
maxSteps: 30,
|
|
11
|
+
usageInput: 4096,
|
|
12
|
+
usageOutput: 1024,
|
|
13
|
+
repairs: 1,
|
|
14
|
+
status: 'running',
|
|
15
|
+
};
|
|
16
|
+
const { lastFrame } = render(_jsx(StatusLine, { snapshot: snapshot }));
|
|
17
|
+
const out = lastFrame();
|
|
18
|
+
expect(out).toContain('gpt-4o-mini');
|
|
19
|
+
expect(out).toContain('3');
|
|
20
|
+
expect(out).toContain('30');
|
|
21
|
+
expect(out).toContain('repairs');
|
|
22
|
+
expect(out).toContain('running');
|
|
23
|
+
});
|
|
24
|
+
it('renders different status colors for done / error / aborted', () => {
|
|
25
|
+
for (const status of ['done', 'error', 'aborted', 'idle']) {
|
|
26
|
+
const { lastFrame } = render(_jsx(StatusLine, { snapshot: {
|
|
27
|
+
model: 'm', step: 1, maxSteps: 10, usageInput: 0, usageOutput: 0, repairs: 0, status,
|
|
28
|
+
} }));
|
|
29
|
+
const out = lastFrame();
|
|
30
|
+
expect(out).toContain(status);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
it('formats usage in KiB', () => {
|
|
34
|
+
const { lastFrame } = render(_jsx(StatusLine, { snapshot: {
|
|
35
|
+
model: 'm', step: 1, maxSteps: 10, usageInput: 2048, usageOutput: 0, repairs: 0, status: 'idle',
|
|
36
|
+
} }));
|
|
37
|
+
expect(lastFrame()).toContain('2.0K');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript — a scrollable list of transcript entries.
|
|
3
|
+
*
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* State management: parent owns the list, passes a fresh array on every
|
|
9
|
+
* update. This component is pure presentational.
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react';
|
|
12
|
+
export type TranscriptItem = {
|
|
13
|
+
id: string;
|
|
14
|
+
kind: 'text';
|
|
15
|
+
text: string;
|
|
16
|
+
role: 'user' | 'assistant';
|
|
17
|
+
} | {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: 'tool';
|
|
20
|
+
name: string;
|
|
21
|
+
id_call: string;
|
|
22
|
+
args: string;
|
|
23
|
+
result?: string;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
latencyMs?: number;
|
|
26
|
+
collapsed?: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: 'policy';
|
|
30
|
+
name: string;
|
|
31
|
+
action: 'allow' | 'ask' | 'deny';
|
|
32
|
+
reason?: string;
|
|
33
|
+
} | {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: 'error';
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function Transcript({ items }: {
|
|
39
|
+
items: TranscriptItem[];
|
|
40
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
export function Transcript({ items }) {
|
|
4
|
+
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
|
+
}
|
|
6
|
+
function TranscriptRow({ item }) {
|
|
7
|
+
if (item.kind === 'text') {
|
|
8
|
+
const color = item.role === 'user' ? 'blue' : undefined;
|
|
9
|
+
return (_jsx(Box, { marginY: 0, children: _jsxs(Text, { color: color, children: [item.role === 'user' ? '> ' : '', item.text] }) }));
|
|
10
|
+
}
|
|
11
|
+
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] }))] }));
|
|
14
|
+
}
|
|
15
|
+
if (item.kind === 'policy') {
|
|
16
|
+
const color = item.action === 'allow' ? 'green' : item.action === 'deny' ? 'red' : 'yellow';
|
|
17
|
+
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
|
+
}
|
|
19
|
+
if (item.kind === 'error') {
|
|
20
|
+
return (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", children: ["[error] ", item.message] }) }));
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function truncate(s, max) {
|
|
25
|
+
if (s.length <= max)
|
|
26
|
+
return s;
|
|
27
|
+
return s.slice(0, max) + '…';
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { Transcript } from './transcript.js';
|
|
5
|
+
describe('Transcript', () => {
|
|
6
|
+
it('shows the empty-state hint when no items', () => {
|
|
7
|
+
const { lastFrame } = render(_jsx(Transcript, { items: [] }));
|
|
8
|
+
expect(lastFrame()).toMatch(/Type a prompt/);
|
|
9
|
+
});
|
|
10
|
+
it('renders user text with the > prefix', () => {
|
|
11
|
+
const items = [
|
|
12
|
+
{ id: '1', kind: 'text', text: 'hello', role: 'user' },
|
|
13
|
+
];
|
|
14
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
15
|
+
expect(lastFrame()).toContain('> hello');
|
|
16
|
+
});
|
|
17
|
+
it('renders assistant text without prefix', () => {
|
|
18
|
+
const items = [
|
|
19
|
+
{ id: '1', kind: 'text', text: 'world', role: 'assistant' },
|
|
20
|
+
];
|
|
21
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
22
|
+
expect(lastFrame()).toContain('world');
|
|
23
|
+
expect(lastFrame()).not.toContain('> world');
|
|
24
|
+
});
|
|
25
|
+
it('renders a tool call block with header and result', () => {
|
|
26
|
+
const items = [
|
|
27
|
+
{
|
|
28
|
+
id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1',
|
|
29
|
+
args: '{"path":"x"}', result: 'content', isError: false, latencyMs: 12,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
33
|
+
const out = lastFrame();
|
|
34
|
+
expect(out).toContain('[tool]');
|
|
35
|
+
expect(out).toContain('read_file');
|
|
36
|
+
expect(out).toContain('(12ms)');
|
|
37
|
+
expect(out).toContain('content');
|
|
38
|
+
});
|
|
39
|
+
it('collapses long tool args/results when collapsed=true', () => {
|
|
40
|
+
const items = [
|
|
41
|
+
{
|
|
42
|
+
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,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
48
|
+
const out = lastFrame();
|
|
49
|
+
expect(out).toContain('collapsed');
|
|
50
|
+
expect(out).toContain('500');
|
|
51
|
+
expect(out).not.toContain('x'.repeat(50));
|
|
52
|
+
});
|
|
53
|
+
it('renders policy decisions with the right color cue', () => {
|
|
54
|
+
const items = [
|
|
55
|
+
{ id: 'p1', kind: 'policy', name: 'shell_exec', action: 'deny', reason: 'r' },
|
|
56
|
+
{ id: 'p2', kind: 'policy', name: 'shell_exec', action: 'allow' },
|
|
57
|
+
];
|
|
58
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
59
|
+
const out = lastFrame();
|
|
60
|
+
expect(out).toContain('deny');
|
|
61
|
+
expect(out).toContain('allow');
|
|
62
|
+
expect(out).toContain('shell_exec');
|
|
63
|
+
});
|
|
64
|
+
it('renders error items', () => {
|
|
65
|
+
const items = [
|
|
66
|
+
{ id: 'e1', kind: 'error', message: 'oops' },
|
|
67
|
+
];
|
|
68
|
+
const { lastFrame } = render(_jsx(Transcript, { items: items }));
|
|
69
|
+
expect(lastFrame()).toContain('[error] oops');
|
|
70
|
+
});
|
|
71
|
+
});
|