klyro 0.1.3 → 0.1.5
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.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +280 -52
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +369 -23
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +7 -14
- package/dist/tui/app.js +228 -26
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.d.ts +6 -0
- package/dist/tui/snapshot.test.js +87 -0
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
package/dist/tui/app.js
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
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.
|
|
4
|
+
* 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
|
|
13
5
|
*/
|
|
14
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
15
7
|
import { Box, Text, useInput } from 'ink';
|
|
16
8
|
import { StatusLine } from './status.js';
|
|
17
9
|
import { Transcript } from './transcript.js';
|
|
@@ -19,11 +11,60 @@ import { Header } from './header.js';
|
|
|
19
11
|
import { ApprovalModal, TuiApprovalBridge } from './approval.js';
|
|
20
12
|
import { PlanView } from './plan.js';
|
|
21
13
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
22
18
|
let _itemCounter = 0;
|
|
23
19
|
function nextId(prefix) {
|
|
24
20
|
_itemCounter += 1;
|
|
25
21
|
return `${prefix}-${_itemCounter}`;
|
|
26
22
|
}
|
|
23
|
+
function getGitBranch(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
|
|
26
|
+
if (r.status === 0 && r.stdout)
|
|
27
|
+
return r.stdout.trim().slice(0, 40);
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
function getHistoryPath() {
|
|
33
|
+
const home = os.homedir() || process.cwd();
|
|
34
|
+
return path.join(home, '.klyro', 'history');
|
|
35
|
+
}
|
|
36
|
+
function loadHistory(cwd) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(getHistoryPath(), 'utf-8');
|
|
39
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
try {
|
|
43
|
+
const obj = JSON.parse(line);
|
|
44
|
+
if (obj.cwd === cwd && typeof obj.text === 'string')
|
|
45
|
+
out.push(obj.text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// legacy plain text per line
|
|
49
|
+
if (line.trim())
|
|
50
|
+
out.push(line.trim());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out.slice(-200);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function appendHistory(cwd, text) {
|
|
60
|
+
try {
|
|
61
|
+
const p = getHistoryPath();
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
const entry = JSON.stringify({ cwd, text, ts: Date.now() });
|
|
64
|
+
fs.appendFileSync(p, entry + '\n', 'utf-8');
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
}
|
|
27
68
|
export function App(props) {
|
|
28
69
|
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
29
70
|
const [input, setInput] = useState('');
|
|
@@ -41,41 +82,176 @@ export function App(props) {
|
|
|
41
82
|
status: 'idle',
|
|
42
83
|
...props.initialStatus,
|
|
43
84
|
});
|
|
44
|
-
|
|
85
|
+
const [history, setHistory] = useState(() => loadHistory(props.cwd));
|
|
86
|
+
const historyIndexRef = useRef(-1);
|
|
87
|
+
const lastCtrlCRef = useRef(0);
|
|
88
|
+
const [gitBranch, setGitBranch] = useState(() => getGitBranch(props.cwd));
|
|
89
|
+
const [queued, setQueued] = useState(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const t = setInterval(() => setGitBranch(getGitBranch(props.cwd)), 5000);
|
|
92
|
+
return () => clearInterval(t);
|
|
93
|
+
}, [props.cwd]);
|
|
45
94
|
useEffect(() => {
|
|
46
95
|
return bridge.subscribe((p) => setAwaitingApproval(p !== null));
|
|
47
96
|
}, [bridge]);
|
|
48
97
|
const append = useCallback((item) => {
|
|
49
|
-
setTranscript((prev) =>
|
|
98
|
+
setTranscript((prev) => {
|
|
99
|
+
const last = prev[prev.length - 1];
|
|
100
|
+
if (last?.kind === 'text' &&
|
|
101
|
+
item.kind === 'text' &&
|
|
102
|
+
last.role === 'assistant' &&
|
|
103
|
+
item.role === 'assistant' &&
|
|
104
|
+
last.id === item.id) {
|
|
105
|
+
return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
|
|
106
|
+
}
|
|
107
|
+
return [...prev, item];
|
|
108
|
+
});
|
|
109
|
+
}, []);
|
|
110
|
+
const updateStatus = useCallback((s) => {
|
|
111
|
+
setStatus((prev) => ({ ...prev, ...s }));
|
|
50
112
|
}, []);
|
|
113
|
+
const updatePlan = useCallback((p) => {
|
|
114
|
+
setPlan(p);
|
|
115
|
+
setPlanExpanded(true);
|
|
116
|
+
}, []);
|
|
117
|
+
const onMountedRef = useRef(props.onMounted);
|
|
118
|
+
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
51
119
|
useEffect(() => {
|
|
120
|
+
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
52
121
|
globalThis.__klyroAppAppend = append;
|
|
53
|
-
globalThis.__klyroAppStatus =
|
|
54
|
-
globalThis.__klyroAppPlan =
|
|
55
|
-
setPlan(p);
|
|
56
|
-
setPlanExpanded(true);
|
|
57
|
-
};
|
|
122
|
+
globalThis.__klyroAppStatus = updateStatus;
|
|
123
|
+
globalThis.__klyroAppPlan = updatePlan;
|
|
58
124
|
return () => {
|
|
59
125
|
delete globalThis.__klyroAppAppend;
|
|
60
126
|
delete globalThis.__klyroAppStatus;
|
|
61
127
|
delete globalThis.__klyroAppPlan;
|
|
62
128
|
};
|
|
63
|
-
}, [append]);
|
|
129
|
+
}, [append, updateStatus, updatePlan]);
|
|
130
|
+
// Handle Windows raw-mode fallback warning
|
|
131
|
+
const [rawModeWarning, setRawModeWarning] = useState(null);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
try {
|
|
134
|
+
const stdin = process.stdin;
|
|
135
|
+
if (stdin.isTTY && typeof stdin.setRawMode !== 'function') {
|
|
136
|
+
setRawModeWarning('Raw mode not available (mintty) — input may be limited');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* ignore */ }
|
|
140
|
+
}, []);
|
|
141
|
+
// Queue next message if typed during stream (2.4)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
144
|
+
const toSend = queued;
|
|
145
|
+
setQueued(null);
|
|
146
|
+
const trimmed = toSend.trim();
|
|
147
|
+
if (!trimmed)
|
|
148
|
+
return;
|
|
149
|
+
append({ id: nextId('text'), kind: 'text', text: toSend, role: 'user' });
|
|
150
|
+
const cmd = parseSlash(trimmed);
|
|
151
|
+
if (cmd.kind === 'prompt')
|
|
152
|
+
void props.onPrompt(cmd.text);
|
|
153
|
+
else if (cmd.kind === 'plan') {
|
|
154
|
+
if (plan.length > 0)
|
|
155
|
+
setPlanExpanded((v) => !v);
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
void props.onSlash(cmd);
|
|
159
|
+
}
|
|
160
|
+
}, [queued, status.status, awaitingApproval, plan.length, append]);
|
|
64
161
|
useInput((inputStr, key) => {
|
|
65
|
-
if (
|
|
162
|
+
if (awaitingApproval)
|
|
163
|
+
return;
|
|
164
|
+
if (status.status === 'running') {
|
|
165
|
+
if (key.ctrl && inputStr === 'c') {
|
|
166
|
+
void props.onSlash({ kind: 'quit' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (key.return) {
|
|
170
|
+
const value = input.trim();
|
|
171
|
+
if (!value)
|
|
172
|
+
return;
|
|
173
|
+
setQueued(value);
|
|
174
|
+
setInput('');
|
|
175
|
+
append({ id: nextId('text'), kind: 'text', text: `queued: ${value.slice(0, 80)}`, role: 'assistant' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!key.ctrl && !key.meta) {
|
|
179
|
+
// Show typing indicator but don't change input (queued mode)
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// History navigation
|
|
185
|
+
if (key.upArrow) {
|
|
186
|
+
if (history.length === 0)
|
|
187
|
+
return;
|
|
188
|
+
if (historyIndexRef.current === -1)
|
|
189
|
+
historyIndexRef.current = history.length - 1;
|
|
190
|
+
else if (historyIndexRef.current > 0)
|
|
191
|
+
historyIndexRef.current--;
|
|
192
|
+
setInput(history[historyIndexRef.current] ?? '');
|
|
66
193
|
return;
|
|
194
|
+
}
|
|
195
|
+
if (key.downArrow) {
|
|
196
|
+
if (historyIndexRef.current === -1)
|
|
197
|
+
return;
|
|
198
|
+
historyIndexRef.current++;
|
|
199
|
+
if (historyIndexRef.current >= history.length) {
|
|
200
|
+
historyIndexRef.current = -1;
|
|
201
|
+
setInput('');
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
setInput(history[historyIndexRef.current] ?? '');
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Ctrl+R search — simple: cycle history
|
|
209
|
+
if (key.ctrl && inputStr === 'r') {
|
|
210
|
+
if (history.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const term = input.toLowerCase();
|
|
213
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
214
|
+
if (history[i].toLowerCase().includes(term)) {
|
|
215
|
+
setInput(history[i]);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
67
221
|
if (key.return) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
222
|
+
// Multiline: trailing \ or Shift+Enter (where supported, key.shift is true)
|
|
223
|
+
// Ink's key object has `shift` for Shift+Enter on some terminals
|
|
224
|
+
const isShiftEnter = key.shift === true;
|
|
225
|
+
if (isShiftEnter || input.endsWith('\\')) {
|
|
226
|
+
// Replace trailing \ with newline, or just add newline for Shift+Enter
|
|
227
|
+
if (input.endsWith('\\'))
|
|
228
|
+
setInput((v) => v.slice(0, -1) + '\n');
|
|
229
|
+
else
|
|
230
|
+
setInput((v) => v + '\n');
|
|
71
231
|
return;
|
|
232
|
+
}
|
|
233
|
+
const value = input;
|
|
234
|
+
// Preserve newlines for bracketed paste — don't trim inner newlines, only outer
|
|
235
|
+
const trimmedOuter = value.replace(/^\s+|\s+$/g, '');
|
|
236
|
+
if (!trimmedOuter) {
|
|
237
|
+
setInput('');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Check for Ctrl+C double at empty prompt handled below, but here handle submit
|
|
241
|
+
setInput('');
|
|
242
|
+
historyIndexRef.current = -1;
|
|
243
|
+
// Save to history
|
|
244
|
+
setHistory((prev) => {
|
|
245
|
+
const next = [...prev, value];
|
|
246
|
+
appendHistory(props.cwd, value);
|
|
247
|
+
return next.slice(-200);
|
|
248
|
+
});
|
|
72
249
|
append({ id: nextId('text'), kind: 'text', text: value, role: 'user' });
|
|
73
|
-
const cmd = parseSlash(
|
|
250
|
+
const cmd = parseSlash(trimmedOuter);
|
|
74
251
|
if (cmd.kind === 'prompt') {
|
|
75
252
|
void props.onPrompt(cmd.text);
|
|
76
253
|
}
|
|
77
254
|
else if (cmd.kind === 'plan') {
|
|
78
|
-
// Local UI command — toggle the plan view inline.
|
|
79
255
|
if (plan.length > 0)
|
|
80
256
|
setPlanExpanded((v) => !v);
|
|
81
257
|
}
|
|
@@ -88,13 +264,39 @@ export function App(props) {
|
|
|
88
264
|
setInput((v) => v.slice(0, -1));
|
|
89
265
|
return;
|
|
90
266
|
}
|
|
267
|
+
// Ctrl+C double at empty prompt exits
|
|
91
268
|
if (key.ctrl && inputStr === 'c') {
|
|
269
|
+
if (input === '') {
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
if (now - lastCtrlCRef.current < 1500) {
|
|
272
|
+
void props.onSlash({ kind: 'quit' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
lastCtrlCRef.current = now;
|
|
276
|
+
append({ id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Single Ctrl+C cancels input
|
|
280
|
+
setInput('');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (key.ctrl && inputStr === 'd') {
|
|
92
284
|
void props.onSlash({ kind: 'quit' });
|
|
93
285
|
return;
|
|
94
286
|
}
|
|
287
|
+
if (key.ctrl && inputStr === 'l') {
|
|
288
|
+
// Clear — keep session but clear transcript marker
|
|
289
|
+
append({ id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Handle bracketed paste: inputStr may contain \r\n or multiple lines
|
|
95
293
|
if (!key.ctrl && !key.meta) {
|
|
96
|
-
|
|
294
|
+
// Preserve all characters including newlines from paste
|
|
295
|
+
// Normalize \r\n to \n
|
|
296
|
+
const normalized = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
297
|
+
setInput((v) => v + normalized);
|
|
97
298
|
}
|
|
98
299
|
});
|
|
99
|
-
|
|
300
|
+
const promptStr = `klyro › ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
|
|
301
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(Header, { cwd: props.cwd, model: status.model, step: status.step, maxSteps: status.maxSteps }), rawModeWarning ? _jsx(Box, { children: _jsx(Text, { color: "yellow", children: rawModeWarning }) }) : null, _jsx(StatusLine, { snapshot: status }), plan.length > 0 ? (_jsx(PlanView, { steps: plan, expanded: planExpanded, onToggle: () => setPlanExpanded((v) => !v) })) : null, _jsx(Transcript, { items: transcript }), awaitingApproval ? _jsx(ApprovalModal, { bridge: bridge }) : null, _jsxs(Box, { borderStyle: "single", borderColor: awaitingApproval ? 'yellow' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "gray", children: awaitingApproval ? '! ' : `${promptStr} ` }), _jsx(Text, { children: awaitingApproval ? '(awaiting approval — see above)' : input }), status.status === 'running' ? _jsx(Text, { color: "cyan", children: " \u258D" }) : _jsx(Text, { children: "\u258D" })] }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: slash completion \u00B7 Shift+Enter: newline \u00B7 Ctrl+C twice: exit \u00B7 Ctrl+R: history" }) })] }));
|
|
100
302
|
}
|
package/dist/tui/approval.js
CHANGED
|
@@ -56,13 +56,34 @@ export function ApprovalModal({ bridge }) {
|
|
|
56
56
|
useEffect(() => {
|
|
57
57
|
return bridge.subscribe((p) => setPending(p?.req ?? null));
|
|
58
58
|
}, [bridge]);
|
|
59
|
+
const [explain, setExplain] = useState(false);
|
|
59
60
|
useInput((inputStr, key) => {
|
|
60
61
|
if (!pending)
|
|
61
62
|
return;
|
|
62
63
|
const c = inputStr.toLowerCase();
|
|
63
|
-
if (c === 'y'
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
if (c === 'y') {
|
|
65
|
+
bridge.resolve('allow');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (c === 'a') {
|
|
69
|
+
bridge.resolve('always');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (c === 'A') {
|
|
73
|
+
bridge.resolve('always');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (c === 'd' || c === 'n') {
|
|
77
|
+
bridge.resolve('deny');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (c === 'e') {
|
|
81
|
+
// Edit: for MVP, treat as deny with edit hint — model will be told to re-read and retry
|
|
82
|
+
bridge.resolve('deny');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (c === '?') {
|
|
86
|
+
setExplain((v) => !v);
|
|
66
87
|
return;
|
|
67
88
|
}
|
|
68
89
|
if (key.return) {
|
|
@@ -72,5 +93,5 @@ export function ApprovalModal({ bridge }) {
|
|
|
72
93
|
});
|
|
73
94
|
if (!pending)
|
|
74
95
|
return null;
|
|
75
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: ", pending.reason] }), pending.summary ? _jsxs(Text, { children: [" ", pending.summary] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y]
|
|
96
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: ", pending.reason] }), pending.summary ? _jsxs(Text, { children: [" ", pending.summary] }) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
|
|
76
97
|
}
|
|
@@ -47,7 +47,7 @@ describe('ApprovalModal', () => {
|
|
|
47
47
|
expect(out).toContain('shell_exec');
|
|
48
48
|
expect(out).toContain('destructive');
|
|
49
49
|
expect(out).toContain('rm -rf /');
|
|
50
|
-
expect(out).toContain('
|
|
50
|
+
expect(out).toContain('once');
|
|
51
51
|
expect(out).toContain('deny');
|
|
52
52
|
bridge.resolve('deny');
|
|
53
53
|
await promise;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot tests for the full TUI App — render various states and assert
|
|
4
|
+
* the exact visible frame. This is the "what does the user actually see"
|
|
5
|
+
* ground truth.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { render } from 'ink-testing-library';
|
|
9
|
+
import { App } from './app.js';
|
|
10
|
+
const DEFAULT_PROPS = {
|
|
11
|
+
cwd: '/projects/demo',
|
|
12
|
+
onPrompt: async () => { },
|
|
13
|
+
onSlash: async () => { },
|
|
14
|
+
};
|
|
15
|
+
describe('App visual snapshot', () => {
|
|
16
|
+
it('renders header + statusline + transcript + input at idle', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
|
|
18
|
+
const frame = lastFrame();
|
|
19
|
+
// Header should be visible (uppercase KLYRO as rendered)
|
|
20
|
+
expect(frame).toContain('KLYRO');
|
|
21
|
+
expect(frame).toContain('demo'); // cwd basename
|
|
22
|
+
expect(frame).toContain('gpt-4o-mini');
|
|
23
|
+
// Status line should show
|
|
24
|
+
expect(frame).toMatch(/idle/i);
|
|
25
|
+
// Input prompt should be visible (klyro › per 1.4)
|
|
26
|
+
expect(frame).toMatch(/klyro|›/);
|
|
27
|
+
});
|
|
28
|
+
it('renders a transcript with assistant text', () => {
|
|
29
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
30
|
+
{ id: 'u1', kind: 'text', text: 'hello', role: 'user' },
|
|
31
|
+
{ id: 'a1', kind: 'text', text: 'hi there', role: 'assistant' },
|
|
32
|
+
] }));
|
|
33
|
+
const frame = lastFrame();
|
|
34
|
+
expect(frame).toContain('hello');
|
|
35
|
+
expect(frame).toContain('hi there');
|
|
36
|
+
});
|
|
37
|
+
it('does not render plan view when plan is empty', () => {
|
|
38
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [] }));
|
|
39
|
+
const frame = lastFrame();
|
|
40
|
+
// When plan is empty, no plan section should be visible
|
|
41
|
+
expect(frame).not.toContain('Plan');
|
|
42
|
+
});
|
|
43
|
+
it('renders plan view when plan is populated via mounted hooks', async () => {
|
|
44
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
|
|
45
|
+
const g = globalThis;
|
|
46
|
+
// Poll for hooks installed by useEffect (avoids flaky fixed timeout)
|
|
47
|
+
for (let i = 0; i < 10 && !g.__klyroAppPlan; i++)
|
|
48
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
49
|
+
g.__klyroAppPlan?.([
|
|
50
|
+
{ id: '1', title: 'Read files', status: 'done' },
|
|
51
|
+
{ id: '2', title: 'Edit code', status: 'in_progress', files: ['src/x.ts'] },
|
|
52
|
+
]);
|
|
53
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
54
|
+
const frame = lastFrame();
|
|
55
|
+
expect(frame).toContain('Read files');
|
|
56
|
+
expect(frame).toContain('Edit code');
|
|
57
|
+
});
|
|
58
|
+
it('renders file_changed inline (the colored line)', () => {
|
|
59
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
60
|
+
{ id: 'fc1', kind: 'file_changed', path: 'src/foo.ts', op: 'modified' },
|
|
61
|
+
] }));
|
|
62
|
+
const frame = lastFrame();
|
|
63
|
+
expect(frame).toContain('src/foo.ts');
|
|
64
|
+
expect(frame).toMatch(/modified|\~/);
|
|
65
|
+
});
|
|
66
|
+
it('diff transcript item renders the diff box', () => {
|
|
67
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
68
|
+
{
|
|
69
|
+
id: 'd1',
|
|
70
|
+
kind: 'diff',
|
|
71
|
+
summary: '1 file(s) changed',
|
|
72
|
+
hunks: [{
|
|
73
|
+
path: 'src/x.ts',
|
|
74
|
+
lines: [
|
|
75
|
+
{ kind: 'header', text: '@@ -1 +1 @@' },
|
|
76
|
+
{ kind: 'remove', text: 'const a = 1;' },
|
|
77
|
+
{ kind: 'add', text: 'const a = 2;' },
|
|
78
|
+
],
|
|
79
|
+
}],
|
|
80
|
+
},
|
|
81
|
+
] }));
|
|
82
|
+
const frame = lastFrame();
|
|
83
|
+
expect(frame).toContain('src/x.ts');
|
|
84
|
+
expect(frame).toContain('const a = 2;');
|
|
85
|
+
expect(frame).toContain('+ ');
|
|
86
|
+
});
|
|
87
|
+
});
|
package/dist/tui/status.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box } from 'ink';
|
|
3
|
+
import { estimateCost } from '../providers/model-info.js';
|
|
3
4
|
export function StatusLine({ snapshot }) {
|
|
4
5
|
const { model, step, maxSteps, usageInput, usageOutput, repairs, status } = snapshot;
|
|
5
6
|
const statusColor = status === 'running' ? 'cyan' :
|
|
@@ -9,5 +10,8 @@ export function StatusLine({ snapshot }) {
|
|
|
9
10
|
'gray';
|
|
10
11
|
const usageKbIn = (usageInput / 1024).toFixed(1);
|
|
11
12
|
const usageKbOut = (usageOutput / 1024).toFixed(1);
|
|
12
|
-
|
|
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] }) })] }));
|
|
13
17
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.5 — Logging (pino JSON to ~/.klyro/logs/klyro-YYYY-MM-DD.log, 14-day rotation, secret redaction)
|
|
3
|
+
* Minimal implementation: JSONL append, redaction via secret-redactor, --debug pretty tee.
|
|
4
|
+
*/
|
|
5
|
+
export interface LogEntry {
|
|
6
|
+
ts: string;
|
|
7
|
+
level: 'debug' | 'info' | 'warn' | 'error';
|
|
8
|
+
msg: string;
|
|
9
|
+
data?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function log(entry: LogEntry): Promise<void>;
|
|
12
|
+
export declare function logSync(entry: LogEntry): void;
|
package/dist/util/log.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.5 — Logging (pino JSON to ~/.klyro/logs/klyro-YYYY-MM-DD.log, 14-day rotation, secret redaction)
|
|
3
|
+
* Minimal implementation: JSONL append, redaction via secret-redactor, --debug pretty tee.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
10
|
+
function getLogDir() {
|
|
11
|
+
if (process.env.KLYRO_LOG_DIR)
|
|
12
|
+
return process.env.KLYRO_LOG_DIR;
|
|
13
|
+
const home = os.homedir() || process.cwd();
|
|
14
|
+
return path.join(home, '.klyro', 'logs');
|
|
15
|
+
}
|
|
16
|
+
function getLogFile() {
|
|
17
|
+
const dir = getLogDir();
|
|
18
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
19
|
+
return path.join(dir, `klyro-${date}.log`);
|
|
20
|
+
}
|
|
21
|
+
async function rotateLogs() {
|
|
22
|
+
const dir = getLogDir();
|
|
23
|
+
try {
|
|
24
|
+
const files = await fs.readdir(dir);
|
|
25
|
+
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
if (!f.startsWith('klyro-') || !f.endsWith('.log'))
|
|
28
|
+
continue;
|
|
29
|
+
const fp = path.join(dir, f);
|
|
30
|
+
try {
|
|
31
|
+
const st = await fs.stat(fp);
|
|
32
|
+
if (st.mtimeMs < cutoff)
|
|
33
|
+
await fs.unlink(fp);
|
|
34
|
+
}
|
|
35
|
+
catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore dir not exists */ }
|
|
39
|
+
}
|
|
40
|
+
let _rotateDone = false;
|
|
41
|
+
export async function log(entry) {
|
|
42
|
+
if (!_rotateDone) {
|
|
43
|
+
_rotateDone = true;
|
|
44
|
+
rotateLogs().catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
const redactedMsg = redact(entry.msg);
|
|
47
|
+
const redactedData = entry.data ? JSON.parse(redact(JSON.stringify(entry.data))) : undefined;
|
|
48
|
+
const line = JSON.stringify({ ...entry, msg: redactedMsg, data: redactedData }) + '\n';
|
|
49
|
+
const file = getLogFile();
|
|
50
|
+
try {
|
|
51
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
52
|
+
await fs.appendFile(file, line, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
catch { /* ignore */ }
|
|
55
|
+
if (process.env.KLYRO_LOG_LEVEL === 'debug' || process.env.DEBUG) {
|
|
56
|
+
const pretty = `[${entry.level}] ${entry.msg}${entry.data ? ' ' + JSON.stringify(entry.data) : ''}`;
|
|
57
|
+
if (entry.level === 'error')
|
|
58
|
+
process.stderr.write(pretty + '\n');
|
|
59
|
+
else
|
|
60
|
+
process.stdout.write(pretty + '\n');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function logSync(entry) {
|
|
64
|
+
const redactedMsg = redact(entry.msg);
|
|
65
|
+
const line = JSON.stringify({ ...entry, msg: redactedMsg, ts: entry.ts }) + '\n';
|
|
66
|
+
const file = getLogFile();
|
|
67
|
+
try {
|
|
68
|
+
fsSync.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fsSync.appendFileSync(file, line, 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore */ }
|
|
72
|
+
if (process.env.KLYRO_LOG_LEVEL === 'debug') {
|
|
73
|
+
process.stderr.write(`[${entry.level}] ${redactedMsg}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 8 — auto-detect verification command from project.
|
|
3
|
+
* Picks the most relevant check for the repo so the harness
|
|
4
|
+
* can verify without explicit user config.
|
|
5
|
+
*/
|
|
6
|
+
export declare function detectVerifyCommand(cwd: string): string | null;
|
|
7
|
+
/** Human-readable reason why a command was chosen. */
|
|
8
|
+
export declare function describeVerifyCommand(cmd: string | null): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 8 — auto-detect verification command from project.
|
|
3
|
+
* Picks the most relevant check for the repo so the harness
|
|
4
|
+
* can verify without explicit user config.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
export function detectVerifyCommand(cwd) {
|
|
9
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
10
|
+
if (fs.existsSync(pkgPath)) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(pkgPath, 'utf-8');
|
|
13
|
+
const json = JSON.parse(raw);
|
|
14
|
+
// Prefer test if available — it's the strongest signal
|
|
15
|
+
if (json.scripts?.test) {
|
|
16
|
+
if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml')))
|
|
17
|
+
return 'pnpm test';
|
|
18
|
+
if (fs.existsSync(path.join(cwd, 'yarn.lock')))
|
|
19
|
+
return 'yarn test';
|
|
20
|
+
if (fs.existsSync(path.join(cwd, 'bun.lockb')))
|
|
21
|
+
return 'bun test';
|
|
22
|
+
return 'npm test';
|
|
23
|
+
}
|
|
24
|
+
if (json.scripts?.build)
|
|
25
|
+
return 'npm run build';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// ignore parse errors
|
|
29
|
+
}
|
|
30
|
+
if (fs.existsSync(path.join(cwd, 'tsconfig.json')))
|
|
31
|
+
return 'npx tsc --noEmit';
|
|
32
|
+
}
|
|
33
|
+
if (fs.existsSync(path.join(cwd, 'tsconfig.json')))
|
|
34
|
+
return 'npx tsc --noEmit';
|
|
35
|
+
if (fs.existsSync(path.join(cwd, 'Makefile')) || fs.existsSync(path.join(cwd, 'makefile')))
|
|
36
|
+
return 'make test';
|
|
37
|
+
if (fs.existsSync(path.join(cwd, 'pyproject.toml')) || fs.existsSync(path.join(cwd, 'requirements.txt')))
|
|
38
|
+
return 'pytest';
|
|
39
|
+
if (fs.existsSync(path.join(cwd, 'go.mod')))
|
|
40
|
+
return 'go test ./...';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** Human-readable reason why a command was chosen. */
|
|
44
|
+
export function describeVerifyCommand(cmd) {
|
|
45
|
+
if (!cmd)
|
|
46
|
+
return 'no verification command auto-detected';
|
|
47
|
+
if (cmd.includes('test'))
|
|
48
|
+
return `auto-detected: ${cmd}`;
|
|
49
|
+
if (cmd.includes('tsc'))
|
|
50
|
+
return `auto-detected: ${cmd} (typecheck)`;
|
|
51
|
+
return `auto-detected: ${cmd}`;
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klyro",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"klyro": "dist/index.js"
|
|
9
|
+
"klyro": "dist/index.js",
|
|
10
|
+
"ky": "dist/index.js"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"dist",
|