klyro 0.1.53 → 0.1.55
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/anthropic-adapter.js +22 -2
- package/dist/agent/registry.js +11 -1
- package/dist/agent/retry.d.ts +5 -0
- package/dist/agent/retry.js +28 -1
- package/dist/agent/runtime.d.ts +2 -0
- package/dist/agent/runtime.js +48 -35
- package/dist/chat.js +2 -2
- package/dist/checkpoints/store.js +86 -7
- package/dist/cli/config.js +13 -1
- package/dist/cli/repl.js +200 -36
- package/dist/cli/run.js +88 -69
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +28 -6
- package/dist/context/tokenizer.js +8 -1
- package/dist/index.js +49 -14
- package/dist/persistence/session.d.ts +2 -0
- package/dist/persistence/session.js +11 -5
- package/dist/persistence/store.d.ts +7 -0
- package/dist/persistence/store.js +77 -28
- package/dist/policy/engine.d.ts +10 -1
- package/dist/policy/engine.js +33 -2
- package/dist/tools/fs/apply-patch.d.ts +16 -1
- package/dist/tools/fs/apply-patch.js +163 -75
- package/dist/tools/fs/edit-file.d.ts +13 -0
- package/dist/tools/fs/edit-file.js +96 -54
- package/dist/tools/fs/list-dir.d.ts +2 -0
- package/dist/tools/fs/list-dir.js +7 -5
- package/dist/tools/fs/multi-edit.js +12 -1
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/read-history.d.ts +2 -2
- package/dist/tools/fs/read-history.js +39 -7
- package/dist/tools/fs/write-file.js +1 -1
- package/dist/tools/normalize.js +11 -1
- package/dist/tools/shell/background.d.ts +5 -0
- package/dist/tools/shell/background.js +29 -0
- package/dist/tools/shell/shell-exec.d.ts +9 -0
- package/dist/tools/shell/shell-exec.js +54 -8
- package/dist/tools/verify/run-verify.js +2 -2
- package/dist/tui/app.js +10 -5
- package/dist/tui/app.test.js +42 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +20 -5
- package/dist/tui/diff.d.ts +5 -19
- package/dist/tui/diff.js +9 -25
- package/dist/tui/status.d.ts +4 -8
- package/dist/tui/status.js +8 -17
- package/dist/tui/transcript.d.ts +13 -20
- package/dist/tui/transcript.js +9 -74
- package/dist/verification/engine.js +4 -4
- package/package.json +1 -1
|
@@ -34,7 +34,7 @@ export const writeFileTool = defineTool({
|
|
|
34
34
|
let needsApproval = false;
|
|
35
35
|
try {
|
|
36
36
|
existing = await fs.readFile(resolved, 'utf-8');
|
|
37
|
-
if (!wasRead(input.path) && existing.length > 200) {
|
|
37
|
+
if (!wasRead(input.path, ctx.cwd) && existing.length > 200) {
|
|
38
38
|
needsApproval = true;
|
|
39
39
|
}
|
|
40
40
|
}
|
package/dist/tools/normalize.js
CHANGED
|
@@ -40,8 +40,18 @@ export function toToolError(err, fallbackCode = TOOL_ERROR_CODES.INTERNAL) {
|
|
|
40
40
|
}
|
|
41
41
|
return { code: fallbackCode, message: String(err) };
|
|
42
42
|
}
|
|
43
|
+
const CUSTOM_TOOL_CODES = new Set([
|
|
44
|
+
'MATCH_NOT_FOUND',
|
|
45
|
+
'MATCH_AMBIGUOUS',
|
|
46
|
+
'UNKNOWN_TOOL',
|
|
47
|
+
'INVALID_INPUT',
|
|
48
|
+
'INVALID_PATCH',
|
|
49
|
+
'HUNK_MISMATCH',
|
|
50
|
+
'POLICY_DENIED',
|
|
51
|
+
'STALE',
|
|
52
|
+
]);
|
|
43
53
|
function isToolErrorCode(code) {
|
|
44
|
-
return Object.values(TOOL_ERROR_CODES).includes(code) || code
|
|
54
|
+
return Object.values(TOOL_ERROR_CODES).includes(code) || CUSTOM_TOOL_CODES.has(code);
|
|
45
55
|
}
|
|
46
56
|
function mapNodeErrorCode(code) {
|
|
47
57
|
switch (code) {
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
3
|
*/
|
|
4
4
|
export declare function startBackground(command: string, cwd: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* argv variant (shell:false) — no shell interpolation possible. Used for
|
|
7
|
+
* constructed commands (e.g. opening an editor on a user-supplied path).
|
|
8
|
+
*/
|
|
9
|
+
export declare function startBackgroundArgv(argv: string[], cwd: string): string;
|
|
5
10
|
export declare function getOutput(id: string, filter?: string): string;
|
|
6
11
|
export declare function killJob(id: string): void;
|
|
7
12
|
export declare function listJobs(): Array<{
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
3
|
*/
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { findBlockedReason } from './shell-exec.js';
|
|
5
6
|
const jobs = new Map();
|
|
6
7
|
let counter = 0;
|
|
7
8
|
const MAX_JOBS = 5;
|
|
@@ -26,6 +27,10 @@ export function startBackground(command, cwd) {
|
|
|
26
27
|
pruneJobs();
|
|
27
28
|
if (jobs.size >= MAX_JOBS)
|
|
28
29
|
throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
|
|
30
|
+
// Same destructive-pattern gate as shell_exec (spawn is shell:true here too).
|
|
31
|
+
const blocked = findBlockedReason(command);
|
|
32
|
+
if (blocked)
|
|
33
|
+
throw new Error(`Command blocked: ${blocked}`);
|
|
29
34
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
30
35
|
const proc = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
31
36
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
@@ -40,6 +45,30 @@ export function startBackground(command, cwd) {
|
|
|
40
45
|
});
|
|
41
46
|
return id;
|
|
42
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* argv variant (shell:false) — no shell interpolation possible. Used for
|
|
50
|
+
* constructed commands (e.g. opening an editor on a user-supplied path).
|
|
51
|
+
*/
|
|
52
|
+
export function startBackgroundArgv(argv, cwd) {
|
|
53
|
+
pruneJobs();
|
|
54
|
+
if (jobs.size >= MAX_JOBS)
|
|
55
|
+
throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
|
|
56
|
+
if (argv.length === 0)
|
|
57
|
+
throw new Error('startBackgroundArgv requires argv');
|
|
58
|
+
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
59
|
+
const proc = spawn(argv[0], argv.slice(1), { cwd, shell: false, windowsHide: true });
|
|
60
|
+
const job = { id, command: argv.join(' '), cwd, proc, output: '', start: Date.now() };
|
|
61
|
+
jobs.set(id, job);
|
|
62
|
+
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
63
|
+
job.output = job.output.slice(-1_000_000); });
|
|
64
|
+
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
65
|
+
job.output = job.output.slice(-1_000_000); });
|
|
66
|
+
proc.on('close', () => {
|
|
67
|
+
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
68
|
+
jobs.delete(id); }, JOB_TTL_MS);
|
|
69
|
+
});
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
43
72
|
export function getOutput(id, filter) {
|
|
44
73
|
const job = jobs.get(id);
|
|
45
74
|
if (!job)
|
|
@@ -15,6 +15,15 @@ declare const InputSchema: z.ZodObject<{
|
|
|
15
15
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
16
16
|
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
17
17
|
}, z.core.$strip>;
|
|
18
|
+
/** Reset the sticky cwd (e.g. after /cd changes the session directory). */
|
|
19
|
+
export declare function resetPersistentCwd(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Shared dangerous-command check (also used by background shells).
|
|
22
|
+
* Returns the block reason, or null when the command is not hard-blocked.
|
|
23
|
+
* NOTE: this covers destructive patterns only — it is NOT an allowlist.
|
|
24
|
+
* Interactive/user-typed commands intentionally bypass it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findBlockedReason(command: string): string | null;
|
|
18
27
|
export interface ShellOutput {
|
|
19
28
|
command: string;
|
|
20
29
|
exitCode: number | null;
|
|
@@ -35,6 +35,10 @@ const FULL_OUTPUT_DIR = (() => {
|
|
|
35
35
|
})();
|
|
36
36
|
// Persistent cwd across calls (3.3)
|
|
37
37
|
let persistentCwd = null;
|
|
38
|
+
/** Reset the sticky cwd (e.g. after /cd changes the session directory). */
|
|
39
|
+
export function resetPersistentCwd() {
|
|
40
|
+
persistentCwd = null;
|
|
41
|
+
}
|
|
38
42
|
// Filtered env — only safe vars, secrets stripped
|
|
39
43
|
const ALLOWED_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
|
|
40
44
|
function filteredEnv(extra) {
|
|
@@ -96,6 +100,19 @@ const DANGEROUS_PATTERNS = [
|
|
|
96
100
|
{ pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
97
101
|
{ pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
98
102
|
];
|
|
103
|
+
/**
|
|
104
|
+
* Shared dangerous-command check (also used by background shells).
|
|
105
|
+
* Returns the block reason, or null when the command is not hard-blocked.
|
|
106
|
+
* NOTE: this covers destructive patterns only — it is NOT an allowlist.
|
|
107
|
+
* Interactive/user-typed commands intentionally bypass it.
|
|
108
|
+
*/
|
|
109
|
+
export function findBlockedReason(command) {
|
|
110
|
+
for (const { pattern, reason } of DANGEROUS_PATTERNS) {
|
|
111
|
+
if (pattern.test(command))
|
|
112
|
+
return reason;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
99
116
|
export const shellExecTool = defineTool({
|
|
100
117
|
name: 'shell_exec',
|
|
101
118
|
description: 'Execute a shell command. Default timeout 120s (max 600s). Output head+tail 30k chars, full to ~/.klyro/tool-output/<id>.txt. Persistent cwd, filtered env, tree-kill on timeout.',
|
|
@@ -112,10 +129,9 @@ export const shellExecTool = defineTool({
|
|
|
112
129
|
throw Object.assign(new Error(`Interactive command blocked: ${input.command.split(' ')[0]} — hint: use non-interactive mode or run in background`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
132
|
+
const blocked = findBlockedReason(input.command);
|
|
133
|
+
if (blocked) {
|
|
134
|
+
throw Object.assign(new Error(`Command blocked: ${blocked}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
|
|
119
135
|
}
|
|
120
136
|
// Persistent cwd
|
|
121
137
|
let cwd = persistentCwd ?? ctx.cwd;
|
|
@@ -137,6 +153,8 @@ export const shellExecTool = defineTool({
|
|
|
137
153
|
const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
138
154
|
const env = filteredEnv(input.env);
|
|
139
155
|
const start = Date.now();
|
|
156
|
+
// Detached on POSIX so timeout kills the whole process GROUP
|
|
157
|
+
// (child.kill alone orphans grandchildren holding the pipes).
|
|
140
158
|
const child = spawn(input.command, {
|
|
141
159
|
cwd,
|
|
142
160
|
env: env,
|
|
@@ -144,6 +162,7 @@ export const shellExecTool = defineTool({
|
|
|
144
162
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
163
|
windowsHide: true,
|
|
146
164
|
signal: ctx.signal,
|
|
165
|
+
detached: process.platform !== 'win32',
|
|
147
166
|
});
|
|
148
167
|
const stdoutChunks = [];
|
|
149
168
|
const stderrChunks = [];
|
|
@@ -151,9 +170,20 @@ export const shellExecTool = defineTool({
|
|
|
151
170
|
const fullStderr = [];
|
|
152
171
|
let totalOut = 0;
|
|
153
172
|
let totalErr = 0;
|
|
173
|
+
// Full-output caps: an infinite emitter (e.g. `yes`) must not OOM the
|
|
174
|
+
// process or fill the disk. Past the cap we keep counting but drop bytes.
|
|
175
|
+
let fullOutBytes = 0;
|
|
176
|
+
let fullErrBytes = 0;
|
|
177
|
+
const MAX_FULL_BYTES = 512 * 1024;
|
|
154
178
|
let truncated = false;
|
|
155
179
|
child.stdout?.on('data', (chunk) => {
|
|
156
|
-
|
|
180
|
+
if (fullOutBytes + chunk.length <= MAX_FULL_BYTES) {
|
|
181
|
+
fullStdout.push(chunk);
|
|
182
|
+
fullOutBytes += chunk.length;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
truncated = true;
|
|
186
|
+
}
|
|
157
187
|
if (totalOut + chunk.length > MAX_OUTPUT_CHARS) {
|
|
158
188
|
truncated = true;
|
|
159
189
|
// Keep head+tail: drop middle, keep first half and last half later
|
|
@@ -165,7 +195,13 @@ export const shellExecTool = defineTool({
|
|
|
165
195
|
totalOut += chunk.length;
|
|
166
196
|
});
|
|
167
197
|
child.stderr?.on('data', (chunk) => {
|
|
168
|
-
|
|
198
|
+
if (fullErrBytes + chunk.length <= MAX_FULL_BYTES) {
|
|
199
|
+
fullStderr.push(chunk);
|
|
200
|
+
fullErrBytes += chunk.length;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
truncated = true;
|
|
204
|
+
}
|
|
169
205
|
if (totalErr + chunk.length > MAX_OUTPUT_CHARS) {
|
|
170
206
|
truncated = true;
|
|
171
207
|
if (stderrChunks.length < 10)
|
|
@@ -200,10 +236,20 @@ export const shellExecTool = defineTool({
|
|
|
200
236
|
catch { /* already gone */ }
|
|
201
237
|
}
|
|
202
238
|
else {
|
|
239
|
+
// Kill the whole process group (spawned detached); fall back to
|
|
240
|
+
// the direct child if the group signal fails.
|
|
203
241
|
try {
|
|
204
|
-
child.
|
|
242
|
+
if (child.pid !== undefined)
|
|
243
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
244
|
+
else
|
|
245
|
+
child.kill('SIGKILL');
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
try {
|
|
249
|
+
child.kill('SIGKILL');
|
|
250
|
+
}
|
|
251
|
+
catch { /* already dead */ }
|
|
205
252
|
}
|
|
206
|
-
catch { /* already dead */ }
|
|
207
253
|
}
|
|
208
254
|
finish({ code: null, signal: 'SIGKILL', timedOut: true });
|
|
209
255
|
}, timeoutMs);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* engine) can pick a more permissive policy for the verify command: it can
|
|
7
7
|
* run `npm test`, which shell_exec might want approval for in some configs.
|
|
8
8
|
*/
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
12
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
@@ -71,7 +71,7 @@ export const runVerifyTool = defineTool({
|
|
|
71
71
|
// Windows grandchild cleanup — see shell-exec.ts for why.
|
|
72
72
|
if (process.platform === 'win32') {
|
|
73
73
|
try {
|
|
74
|
-
|
|
74
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
|
|
75
75
|
}
|
|
76
76
|
catch { /* already gone */ }
|
|
77
77
|
}
|
package/dist/tui/app.js
CHANGED
|
@@ -5,14 +5,16 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
*/
|
|
6
6
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
|
+
import Spinner from 'ink-spinner';
|
|
8
9
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { TuiApprovalBridge } from './approval.js';
|
|
10
|
+
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
10
11
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
11
12
|
import { tokens, g } from './tokens.js';
|
|
12
13
|
import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
|
|
13
14
|
import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
|
|
14
15
|
import { renderMarkdownLines } from './markdown.js';
|
|
15
16
|
import { getTranscriptCommand, } from './transcript-commands.js';
|
|
17
|
+
import { estimateCost, getModelInfo } from '../providers/model-info.js';
|
|
16
18
|
let _id = 0;
|
|
17
19
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
18
20
|
function Header({ cwd, model, version, width }) {
|
|
@@ -633,9 +635,11 @@ export function App(props) {
|
|
|
633
635
|
});
|
|
634
636
|
const ver = props.version ?? '0.1.27';
|
|
635
637
|
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
636
|
-
|
|
638
|
+
// Single cost/context source: model-aware registry rates + window.
|
|
639
|
+
const cost = estimateCost(status.model, status.usageInput, status.usageOutput);
|
|
637
640
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
638
|
-
const
|
|
641
|
+
const ctxWindow = getModelInfo(status.model).contextWindow;
|
|
642
|
+
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
|
|
639
643
|
// Narrow terminals: compact hints so the status bar never wraps mid-word.
|
|
640
644
|
const baseHints = width < 90
|
|
641
645
|
? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
|
|
@@ -680,7 +684,8 @@ export function App(props) {
|
|
|
680
684
|
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
|
|
681
685
|
const marker = isExpanded ? g('expanded') : g('collapsed');
|
|
682
686
|
const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
|
|
683
|
-
|
|
687
|
+
const running = gr.status === 'running';
|
|
688
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), running ? _jsxs(Text, { color: markerColor, children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? (_jsxs(_Fragment, { children: [gr.items.slice(0, 12).map((it) => {
|
|
684
689
|
let friendly = '';
|
|
685
690
|
try {
|
|
686
691
|
const a = JSON.parse(it.args);
|
|
@@ -718,5 +723,5 @@ export function App(props) {
|
|
|
718
723
|
if (it.kind === 'diff')
|
|
719
724
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
|
|
720
725
|
return null;
|
|
721
|
-
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
726
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
722
727
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -394,6 +394,48 @@ describe('App', () => {
|
|
|
394
394
|
expect(frame).toContain('Answer 29');
|
|
395
395
|
expect(frame).not.toContain('user message number 0');
|
|
396
396
|
});
|
|
397
|
+
it('renders the approval modal when a prompt is pending (no deadlock)', async () => {
|
|
398
|
+
const { TuiApprovalBridge } = await import('./approval.js');
|
|
399
|
+
const bridge = new TuiApprovalBridge();
|
|
400
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, approvalBridge: bridge }));
|
|
401
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
402
|
+
let choice;
|
|
403
|
+
const pending = bridge
|
|
404
|
+
.ask({ toolName: 'shell_exec', reason: 'not in allowlist', summary: 'echo hi' })
|
|
405
|
+
.then((c) => {
|
|
406
|
+
choice = c;
|
|
407
|
+
});
|
|
408
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
409
|
+
// The modal must actually render — previously it never mounted, so every
|
|
410
|
+
// policy 'ask' hung the runtime forever.
|
|
411
|
+
expect(lastFrame() ?? '').toMatch(/approval needed/i);
|
|
412
|
+
expect(bridge.resolve('deny')).toBe(true);
|
|
413
|
+
await pending;
|
|
414
|
+
expect(choice).toBe('deny');
|
|
415
|
+
});
|
|
416
|
+
it('shows a working spinner while running (Thinking + status)', async () => {
|
|
417
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, initialStatus: { status: 'running' } }));
|
|
418
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
419
|
+
const frame = lastFrame() ?? '';
|
|
420
|
+
// Animated dots spinner (any braille frame) or its labels must show.
|
|
421
|
+
expect(frame).toMatch(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Thinking|working/);
|
|
422
|
+
expect(frame).toContain('Thinking');
|
|
423
|
+
expect(frame).toContain('working');
|
|
424
|
+
});
|
|
425
|
+
it('running tool group shows a spinner, resolved group shows latency', async () => {
|
|
426
|
+
let hooks = null;
|
|
427
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialStatus: { status: 'running' }, onMounted: (h) => {
|
|
428
|
+
hooks = { append: h.append, updateTool: h.updateTool };
|
|
429
|
+
} }));
|
|
430
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
431
|
+
hooks.append({ id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1', args: '{"path":"a.ts"}', status: 'running' });
|
|
432
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
433
|
+
// Running group: spinner next to the verb (braille frame or fallback text).
|
|
434
|
+
expect(lastFrame() ?? '').toMatch(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Read/);
|
|
435
|
+
hooks.updateTool('c1', { result: 'ok', isError: false, latencyMs: 42, status: 'done' });
|
|
436
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
437
|
+
expect(lastFrame() ?? '').toMatch(/42ms/);
|
|
438
|
+
});
|
|
397
439
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
398
440
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
399
441
|
await new Promise((r) => setTimeout(r, 50));
|
package/dist/tui/approval.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ interface PendingPrompt {
|
|
|
27
27
|
}
|
|
28
28
|
declare class TuiApprovalBridge implements ApprovalPrompt {
|
|
29
29
|
private pending;
|
|
30
|
-
private
|
|
30
|
+
private listeners;
|
|
31
31
|
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
32
32
|
/** Called by the App's useInput to consume the pending prompt. */
|
|
33
33
|
resolve(choice: ApprovalChoice): boolean;
|
package/dist/tui/approval.js
CHANGED
|
@@ -23,11 +23,19 @@ import { useEffect, useState } from 'react';
|
|
|
23
23
|
import { Box, Text, useInput } from 'ink';
|
|
24
24
|
class TuiApprovalBridge {
|
|
25
25
|
pending = null;
|
|
26
|
-
|
|
26
|
+
// Fan-out set: BOTH the App (awaitingApproval gate) and the ApprovalModal
|
|
27
|
+
// subscribe. The old single slot meant the last subscriber silently
|
|
28
|
+
// unsubscribed the other — the modal never rendered and approvals hung.
|
|
29
|
+
listeners = new Set();
|
|
27
30
|
ask(req) {
|
|
28
31
|
return new Promise((resolve) => {
|
|
29
32
|
this.pending = { req, resolve };
|
|
30
|
-
|
|
33
|
+
for (const l of this.listeners) {
|
|
34
|
+
try {
|
|
35
|
+
l(this.pending);
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore listener errors */ }
|
|
38
|
+
}
|
|
31
39
|
});
|
|
32
40
|
}
|
|
33
41
|
/** Called by the App's useInput to consume the pending prompt. */
|
|
@@ -36,7 +44,12 @@ class TuiApprovalBridge {
|
|
|
36
44
|
return false;
|
|
37
45
|
const p = this.pending;
|
|
38
46
|
this.pending = null;
|
|
39
|
-
this.
|
|
47
|
+
for (const l of this.listeners) {
|
|
48
|
+
try {
|
|
49
|
+
l(null);
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore listener errors */ }
|
|
52
|
+
}
|
|
40
53
|
p.resolve(choice);
|
|
41
54
|
return true;
|
|
42
55
|
}
|
|
@@ -45,8 +58,10 @@ class TuiApprovalBridge {
|
|
|
45
58
|
}
|
|
46
59
|
/** Used by useSyncExternalStore-ish wiring in <ApprovalModal/>. */
|
|
47
60
|
subscribe(listener) {
|
|
48
|
-
this.listener
|
|
49
|
-
return () => {
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
this.listeners.delete(listener);
|
|
64
|
+
};
|
|
50
65
|
}
|
|
51
66
|
}
|
|
52
67
|
export { TuiApprovalBridge };
|
package/dist/tui/diff.d.ts
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Diff data model shared by the transcript renderer (app.tsx) and the
|
|
3
|
+
* unified-diff parser (diff-parser.ts).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
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.
|
|
5
|
+
* NOTE: the old DiffView component was removed: App renders diff hunks
|
|
6
|
+
* inline, and the standalone component's tests gave false coverage for UI
|
|
7
|
+
* nobody sees. Only the types remain.
|
|
15
8
|
*/
|
|
16
|
-
import React from 'react';
|
|
17
9
|
export type DiffLineKind = 'add' | 'remove' | 'context' | 'header';
|
|
18
10
|
export interface DiffLine {
|
|
19
11
|
kind: DiffLineKind;
|
|
@@ -23,9 +15,3 @@ export interface DiffHunk {
|
|
|
23
15
|
path: string;
|
|
24
16
|
lines: DiffLine[];
|
|
25
17
|
}
|
|
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;
|
package/dist/tui/diff.js
CHANGED
|
@@ -1,25 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Diff data model shared by the transcript renderer (app.tsx) and the
|
|
3
|
+
* unified-diff parser (diff-parser.ts).
|
|
4
|
+
*
|
|
5
|
+
* NOTE: the old DiffView component was removed: App renders diff hunks
|
|
6
|
+
* inline, and the standalone component's tests gave false coverage for UI
|
|
7
|
+
* nobody sees. Only the types remain.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/tui/status.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status
|
|
2
|
+
* Status snapshot — session state pushed from app.tsx via hooks.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* NOTE: the old StatusLine component was removed: App renders its own
|
|
5
|
+
* status row inline, and the standalone component's tests gave false
|
|
6
|
+
* coverage for UI nobody sees.
|
|
7
7
|
*/
|
|
8
|
-
import React from 'react';
|
|
9
8
|
export interface StatusSnapshot {
|
|
10
9
|
model: string;
|
|
11
10
|
step: number;
|
|
@@ -16,6 +15,3 @@ export interface StatusSnapshot {
|
|
|
16
15
|
status: 'idle' | 'running' | 'done' | 'error' | 'aborted';
|
|
17
16
|
errorMessage?: string;
|
|
18
17
|
}
|
|
19
|
-
export declare function StatusLine({ snapshot }: {
|
|
20
|
-
snapshot: StatusSnapshot;
|
|
21
|
-
}): React.JSX.Element;
|
package/dist/tui/status.js
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
status === 'aborted' ? 'yellow' :
|
|
10
|
-
'gray';
|
|
11
|
-
const usageKbIn = (usageInput / 1024).toFixed(1);
|
|
12
|
-
const usageKbOut = (usageOutput / 1024).toFixed(1);
|
|
13
|
-
const cost = estimateCost(model, usageInput, usageOutput);
|
|
14
|
-
const costStr = cost > 0 ? `$${cost.toFixed(3)}` : `$0.000`;
|
|
15
|
-
const ctxPct = Math.min(99, Math.round(((usageInput + usageOutput) / 128000) * 100));
|
|
16
|
-
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: " \u00B7 " }), _jsx(Text, { color: "green", children: costStr }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", ctxPct, "% ctx"] })] }), _jsx(Text, { children: _jsxs(Text, { color: statusColor, children: ["\u25CF ", status] }) })] }));
|
|
17
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Status snapshot — session state pushed from app.tsx via hooks.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: the old StatusLine component was removed: App renders its own
|
|
5
|
+
* status row inline, and the standalone component's tests gave false
|
|
6
|
+
* coverage for UI nobody sees.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/tui/transcript.d.ts
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transcript —
|
|
2
|
+
* Transcript item types — the scrollable entry list owned by App.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* State management: parent owns the list, passes a fresh array on every
|
|
10
|
-
* update. This component is pure presentational.
|
|
4
|
+
* NOTE: the old Transcript/TranscriptRow/ToolCard presentational components
|
|
5
|
+
* were removed: App renders entries inline (grouped tool activity), and the
|
|
6
|
+
* standalone components' tests gave false coverage for UI nobody sees.
|
|
7
|
+
* Only the item types remain.
|
|
11
8
|
*/
|
|
12
|
-
import
|
|
13
|
-
import { type DiffHunk } from './diff.js';
|
|
14
|
-
/** Patch applied to a running tool item when its result arrives. */
|
|
15
|
-
export interface ToolResultPatch {
|
|
16
|
-
result: string;
|
|
17
|
-
isError: boolean;
|
|
18
|
-
latencyMs: number;
|
|
19
|
-
status: 'done' | 'error';
|
|
20
|
-
}
|
|
9
|
+
import type { DiffHunk } from './diff.js';
|
|
21
10
|
export type TranscriptItem = {
|
|
22
11
|
id: string;
|
|
23
12
|
kind: 'text';
|
|
@@ -55,6 +44,10 @@ export type TranscriptItem = {
|
|
|
55
44
|
hunks: DiffHunk[];
|
|
56
45
|
summary?: string;
|
|
57
46
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
/** Patch applied to a running tool item when its result arrives. */
|
|
48
|
+
export interface ToolResultPatch {
|
|
49
|
+
result: string;
|
|
50
|
+
isError: boolean;
|
|
51
|
+
latencyMs: number;
|
|
52
|
+
status: 'done' | 'error';
|
|
53
|
+
}
|