klyro 0.1.51 → 0.1.53
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/cli/repl.js +34 -8
- package/dist/tui/app.d.ts +2 -1
- package/dist/tui/app.js +38 -8
- package/dist/tui/app.test.js +38 -0
- package/dist/tui/measure.d.ts +2 -0
- package/dist/tui/measure.js +1 -1
- package/dist/tui/snapshot.test.js +11 -0
- package/dist/tui/transcript.d.ts +7 -0
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -142,6 +142,16 @@ export async function startRepl(opts = {}) {
|
|
|
142
142
|
else
|
|
143
143
|
pendingQueue.push({ kind: 'delta', text });
|
|
144
144
|
}
|
|
145
|
+
// Tool results patch the running start-item in place (App.updateTool) so a
|
|
146
|
+
// group resolves to done/error with its real latency instead of ticking
|
|
147
|
+
// forever. Falls back to a standalone item if the start item is gone.
|
|
148
|
+
const pendingToolIds = new Set();
|
|
149
|
+
function queuedToolUpdate(idCall, patch) {
|
|
150
|
+
if (isMounted && directHooks)
|
|
151
|
+
directHooks.updateTool(idCall, patch);
|
|
152
|
+
else
|
|
153
|
+
pendingQueue.push({ kind: 'toolupdate', idCall, patch });
|
|
154
|
+
}
|
|
145
155
|
function queuedStatus(s) {
|
|
146
156
|
lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
|
|
147
157
|
if (isMounted && directHooks)
|
|
@@ -393,6 +403,8 @@ export async function startRepl(opts = {}) {
|
|
|
393
403
|
hooks.updatePlan(ev.plan);
|
|
394
404
|
else if (ev.kind === 'delta')
|
|
395
405
|
hooks.appendDelta(ev.text);
|
|
406
|
+
else if (ev.kind === 'toolupdate')
|
|
407
|
+
hooks.updateTool(ev.idCall, ev.patch);
|
|
396
408
|
else
|
|
397
409
|
hooks.append(ev.item);
|
|
398
410
|
}
|
|
@@ -511,6 +523,7 @@ export async function startRepl(opts = {}) {
|
|
|
511
523
|
activeCallArgs += ev.argsJson;
|
|
512
524
|
}
|
|
513
525
|
else if (ev.kind === 'tool_call_end') {
|
|
526
|
+
pendingToolIds.add(ev.id);
|
|
514
527
|
queuedAppend({
|
|
515
528
|
id: `tool-${ev.id}-${Date.now()}`,
|
|
516
529
|
kind: 'tool',
|
|
@@ -533,17 +546,30 @@ export async function startRepl(opts = {}) {
|
|
|
533
546
|
});
|
|
534
547
|
}
|
|
535
548
|
else if (ev.kind === 'tool_result') {
|
|
536
|
-
|
|
537
|
-
id: `tres-${ev.id}-${Date.now()}`,
|
|
538
|
-
kind: 'tool',
|
|
539
|
-
name: ev.name,
|
|
540
|
-
id_call: ev.id,
|
|
541
|
-
args: '',
|
|
549
|
+
const patch = {
|
|
542
550
|
result: typeof ev.output === 'string' ? ev.output : JSON.stringify(ev.output, null, 2),
|
|
543
551
|
isError: ev.isError,
|
|
544
552
|
latencyMs: ev.latencyMs,
|
|
545
|
-
status: ev.isError ? 'error' : 'done',
|
|
546
|
-
}
|
|
553
|
+
status: (ev.isError ? 'error' : 'done'),
|
|
554
|
+
};
|
|
555
|
+
if (pendingToolIds.delete(ev.id)) {
|
|
556
|
+
// Patch the running start-item in place — no stale spinner.
|
|
557
|
+
queuedToolUpdate(ev.id, patch);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
// Start item gone (e.g. transcript cleared) — keep a record.
|
|
561
|
+
queuedAppend({
|
|
562
|
+
id: `tres-${ev.id}-${Date.now()}`,
|
|
563
|
+
kind: 'tool',
|
|
564
|
+
name: ev.name,
|
|
565
|
+
id_call: ev.id,
|
|
566
|
+
args: '',
|
|
567
|
+
result: patch.result,
|
|
568
|
+
isError: patch.isError,
|
|
569
|
+
latencyMs: patch.latencyMs,
|
|
570
|
+
status: patch.status,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
547
573
|
}
|
|
548
574
|
else if (ev.kind === 'final_text') {
|
|
549
575
|
// streamingId is closed by status change; no extra handling needed
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import type { StatusSnapshot } from './status.js';
|
|
7
|
-
import type { TranscriptItem } from './transcript.js';
|
|
7
|
+
import type { TranscriptItem, ToolResultPatch } from './transcript.js';
|
|
8
8
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
9
|
import type { PlanStep } from '../agent/runtime.js';
|
|
10
10
|
import { type TranscriptScrollHandle } from './transcript-commands.js';
|
|
@@ -28,6 +28,7 @@ export interface AppProps {
|
|
|
28
28
|
scrollHalfPage: (dir: -1 | 1) => void;
|
|
29
29
|
scrollToTop: () => void;
|
|
30
30
|
transcript: TranscriptScrollHandle;
|
|
31
|
+
updateTool: (idCall: string, patch: ToolResultPatch) => void;
|
|
31
32
|
}) => void;
|
|
32
33
|
version?: string;
|
|
33
34
|
isFullscreen?: boolean;
|
package/dist/tui/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
* Klyro TUI - opencode-clean - no clumsy words, correct wrap, markdown, scroll
|
|
4
4
|
* Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
|
|
5
5
|
*/
|
|
6
|
-
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
6
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
@@ -34,7 +34,7 @@ function verbForTool(name) {
|
|
|
34
34
|
return 'Listed';
|
|
35
35
|
if (name === 'grep' || name === 'glob' || name === 'find_files' || name === 'search_files' || name === 'recent_files')
|
|
36
36
|
return 'Searched';
|
|
37
|
-
if (name === 'shell_exec')
|
|
37
|
+
if (name === 'shell_exec' || name === 'run_verify')
|
|
38
38
|
return 'Ran';
|
|
39
39
|
if (name.startsWith('git_'))
|
|
40
40
|
return 'Checked git';
|
|
@@ -83,13 +83,17 @@ function groupTools(items) {
|
|
|
83
83
|
return out;
|
|
84
84
|
}
|
|
85
85
|
// design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
|
|
86
|
-
// **bold**, *italic*, `code`, fences, links, lists. Ink wraps
|
|
86
|
+
// **bold**, *italic*, `code`, fences, links, lists. Ink wraps the text.
|
|
87
|
+
//
|
|
88
|
+
// CRITICAL: this must return a SINGLE <Text> with inline nested parts.
|
|
89
|
+
// A fragment of sibling <Text>s inside the row-direction parent Box lays
|
|
90
|
+
// out as side-by-side COLUMNS (garbled transcript) instead of lines.
|
|
87
91
|
function MarkdownText({ text, dim, width }) {
|
|
88
92
|
void width;
|
|
89
93
|
const lines = useMemo(() => renderMarkdownLines(text), [text]);
|
|
90
94
|
const dimColor = tokens.colors.dim;
|
|
91
95
|
const softColor = tokens.colors.soft;
|
|
92
|
-
return (_jsx(
|
|
96
|
+
return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => (_jsx(Text, { bold: p.bold || undefined, color: p.bold ? (dim ? undefined : softColor) : p.dim ? dimColor : p.code ? softColor : undefined, children: p.text }, j)))] }, i))) }));
|
|
93
97
|
}
|
|
94
98
|
// Chat scroll — scroll.md §5 anchor model adapted to Ink.
|
|
95
99
|
//
|
|
@@ -234,7 +238,13 @@ export function App(props) {
|
|
|
234
238
|
const gr = entry;
|
|
235
239
|
out.push({
|
|
236
240
|
key: gr.id,
|
|
237
|
-
desc: {
|
|
241
|
+
desc: {
|
|
242
|
+
kind: 'group',
|
|
243
|
+
count: gr.items.length,
|
|
244
|
+
expanded: expandedGroups.has(gr.id),
|
|
245
|
+
status: gr.status,
|
|
246
|
+
resultLen: gr.items.reduce((s, x) => s + (x.result?.length ?? 0), 0),
|
|
247
|
+
},
|
|
238
248
|
groupIndex: gi,
|
|
239
249
|
tail: null,
|
|
240
250
|
});
|
|
@@ -385,6 +395,20 @@ export function App(props) {
|
|
|
385
395
|
streamingIdRef.current = null; }, [status.status]);
|
|
386
396
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
387
397
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
398
|
+
// Tool results patch the running start-item IN PLACE (no second item, so a
|
|
399
|
+
// group never stays 'running' forever showing a ticking elapsed timer).
|
|
400
|
+
const updateTool = useCallback((idCall, patch) => {
|
|
401
|
+
setTranscript((prev) => {
|
|
402
|
+
const idx = prev.findIndex((x) => x.kind === 'tool' &&
|
|
403
|
+
x.id_call === idCall &&
|
|
404
|
+
x.status === 'running');
|
|
405
|
+
if (idx === -1)
|
|
406
|
+
return prev;
|
|
407
|
+
const copy = [...prev];
|
|
408
|
+
copy[idx] = { ...copy[idx], ...patch };
|
|
409
|
+
return copy;
|
|
410
|
+
});
|
|
411
|
+
}, []);
|
|
388
412
|
const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
|
|
389
413
|
// Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
|
|
390
414
|
// Stored in refs so the callbacks stay stable while acting on latest state.
|
|
@@ -413,7 +437,7 @@ export function App(props) {
|
|
|
413
437
|
}), []);
|
|
414
438
|
const onMountedRef = useRef(props.onMounted);
|
|
415
439
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
416
|
-
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle]);
|
|
440
|
+
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle, updateTool }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle, updateTool]);
|
|
417
441
|
const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
|
|
418
442
|
n.delete(id);
|
|
419
443
|
else
|
|
@@ -612,9 +636,15 @@ export function App(props) {
|
|
|
612
636
|
const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
|
|
613
637
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
614
638
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
|
|
615
|
-
|
|
639
|
+
// Narrow terminals: compact hints so the status bar never wraps mid-word.
|
|
640
|
+
const baseHints = width < 90
|
|
641
|
+
? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
|
|
642
|
+
: status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
|
|
616
643
|
const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
|
|
617
|
-
|
|
644
|
+
// I1 structural guard: the frame can never exceed terminal rows. Even if a
|
|
645
|
+
// child mis-measures, the root clips — the input/status stay on screen and
|
|
646
|
+
// Ink's cursor math can't corrupt into overlapping text.
|
|
647
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [tiny ? (_jsxs(Text, { color: tokens.colors.warn, children: ["\u26A0 terminal too small (", width, "x", height, ") \u2014 transcript hidden"] })) : null, !tiny && grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : !tiny ? visibleGrouped.map((item) => {
|
|
618
648
|
if (item.verb) {
|
|
619
649
|
const gr = item;
|
|
620
650
|
const isExpanded = expandedGroups.has(gr.id);
|
package/dist/tui/app.test.js
CHANGED
|
@@ -356,6 +356,44 @@ describe('App', () => {
|
|
|
356
356
|
await new Promise((r) => setTimeout(r, 50));
|
|
357
357
|
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
358
358
|
});
|
|
359
|
+
it('tool result patches the running item in place (no stale spinner)', async () => {
|
|
360
|
+
let hooks = null;
|
|
361
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, onMounted: (h) => {
|
|
362
|
+
hooks = { append: h.append, updateTool: h.updateTool };
|
|
363
|
+
} }));
|
|
364
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
365
|
+
expect(hooks).not.toBeNull();
|
|
366
|
+
hooks.append({ id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1', args: '{"path":"a.ts"}', status: 'running' });
|
|
367
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
368
|
+
expect(lastFrame() ?? '').toMatch(/Read/);
|
|
369
|
+
hooks.updateTool('c1', { result: 'ok', isError: false, latencyMs: 42, status: 'done' });
|
|
370
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
371
|
+
const frame = lastFrame() ?? '';
|
|
372
|
+
// Resolved with real latency — exactly one group (start item patched, no duplicate).
|
|
373
|
+
expect(frame).toMatch(/42ms/);
|
|
374
|
+
expect(frame.match(/Read/g)?.length ?? 0).toBeLessThanOrEqual(2);
|
|
375
|
+
});
|
|
376
|
+
it('heavy transcript: frame bounded, input and tail visible', async () => {
|
|
377
|
+
const items = [];
|
|
378
|
+
for (let i = 0; i < 30; i++) {
|
|
379
|
+
items.push({ id: `u-${i}`, kind: 'text', text: `user message number ${i} asking about stuff`, role: 'user' });
|
|
380
|
+
items.push({
|
|
381
|
+
id: `a-${i}`,
|
|
382
|
+
kind: 'text',
|
|
383
|
+
text: `## Answer ${i}\n\nAssistant answer **number ${i}** with a long explanation that wraps across multiple terminal lines.`,
|
|
384
|
+
role: 'assistant',
|
|
385
|
+
});
|
|
386
|
+
items.push({ id: `t-${i}`, kind: 'tool', name: 'read_file', id_call: `c-${i}`, args: '{"path":"a.ts"}', result: 'ok', latencyMs: 5, status: 'done' });
|
|
387
|
+
}
|
|
388
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: items }));
|
|
389
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
390
|
+
const frame = lastFrame() ?? '';
|
|
391
|
+
// I1: frame never exceeds terminal rows; input + tail always on screen.
|
|
392
|
+
expect(frame.split('\n').length).toBeLessThanOrEqual(32);
|
|
393
|
+
expect(frame).toContain('Message Klyro');
|
|
394
|
+
expect(frame).toContain('Answer 29');
|
|
395
|
+
expect(frame).not.toContain('user message number 0');
|
|
396
|
+
});
|
|
359
397
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
360
398
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
361
399
|
await new Promise((r) => setTimeout(r, 50));
|
package/dist/tui/measure.d.ts
CHANGED
package/dist/tui/measure.js
CHANGED
|
@@ -102,7 +102,7 @@ export function blockSig(b) {
|
|
|
102
102
|
case 'assistant':
|
|
103
103
|
return `a:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
|
|
104
104
|
case 'group':
|
|
105
|
-
return `g:${b.count}:${b.expanded ? 1 : 0}`;
|
|
105
|
+
return `g:${b.count}:${b.expanded ? 1 : 0}:${b.status}:${b.resultLen}`;
|
|
106
106
|
case 'error':
|
|
107
107
|
return `e:${b.message.length}:${b.message.slice(-32)}`;
|
|
108
108
|
case 'policy':
|
|
@@ -82,6 +82,17 @@ describe('App visual snapshot', () => {
|
|
|
82
82
|
expect(frame).not.toContain('## Done');
|
|
83
83
|
expect(frame).not.toContain('`npm test`');
|
|
84
84
|
});
|
|
85
|
+
it('multiline assistant text renders as stacked lines, not columns', () => {
|
|
86
|
+
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: [
|
|
87
|
+
{ id: 'm1', kind: 'text', text: 'ZZZTOPLINE\nZZZBOTTOMLINE', role: 'assistant' },
|
|
88
|
+
] }));
|
|
89
|
+
const rows = (lastFrame() ?? '').split('\n');
|
|
90
|
+
const top = rows.findIndex((r) => r.includes('ZZZTOPLINE'));
|
|
91
|
+
const bottom = rows.findIndex((r) => r.includes('ZZZBOTTOMLINE'));
|
|
92
|
+
// Columns bug put both markers on the SAME row; correct render stacks them.
|
|
93
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
94
|
+
expect(bottom).toBeGreaterThan(top);
|
|
95
|
+
});
|
|
85
96
|
it('diff transcript item renders the diff box', () => {
|
|
86
97
|
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: [
|
|
87
98
|
{
|
package/dist/tui/transcript.d.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import React from 'react';
|
|
13
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
|
+
}
|
|
14
21
|
export type TranscriptItem = {
|
|
15
22
|
id: string;
|
|
16
23
|
kind: 'text';
|
package/package.json
CHANGED