klyro 0.1.16 → 0.1.17
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 +13 -39
- package/dist/tui/app.d.ts +6 -4
- package/dist/tui/app.js +49 -49
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -84,6 +84,14 @@ export async function startRepl(opts = {}) {
|
|
|
84
84
|
else
|
|
85
85
|
pendingQueue.push({ kind: 'append', item });
|
|
86
86
|
}
|
|
87
|
+
function queuedDelta(text) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return;
|
|
90
|
+
if (isMounted && directHooks)
|
|
91
|
+
directHooks.appendDelta(text);
|
|
92
|
+
else
|
|
93
|
+
pendingQueue.push({ kind: 'delta', text });
|
|
94
|
+
}
|
|
87
95
|
function queuedStatus(s) {
|
|
88
96
|
lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
|
|
89
97
|
if (isMounted && directHooks)
|
|
@@ -125,6 +133,8 @@ export async function startRepl(opts = {}) {
|
|
|
125
133
|
hooks.updateStatus(ev.patch);
|
|
126
134
|
else if (ev.kind === 'plan')
|
|
127
135
|
hooks.updatePlan(ev.plan);
|
|
136
|
+
else if (ev.kind === 'delta')
|
|
137
|
+
hooks.appendDelta(ev.text);
|
|
128
138
|
else
|
|
129
139
|
hooks.append(ev.item);
|
|
130
140
|
}
|
|
@@ -166,8 +176,6 @@ export async function startRepl(opts = {}) {
|
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
178
|
queuedStatus({ status: 'running', step: 0, model });
|
|
169
|
-
let textBuf = '';
|
|
170
|
-
let pendingTextId = null;
|
|
171
179
|
let activeCallId = null;
|
|
172
180
|
let activeCallName = null;
|
|
173
181
|
let activeCallArgs = '';
|
|
@@ -183,36 +191,11 @@ export async function startRepl(opts = {}) {
|
|
|
183
191
|
persist: sessionId ? { store: tuiStore, sessionId } : undefined,
|
|
184
192
|
onEvent: (ev) => {
|
|
185
193
|
if (ev.kind === 'step_start') {
|
|
186
|
-
// Flush coalesced text before new step
|
|
187
|
-
pendingTextId = null;
|
|
188
194
|
queuedStatus({ step: ev.step });
|
|
189
195
|
}
|
|
190
196
|
else if (ev.kind === 'text_delta') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const g = globalThis;
|
|
194
|
-
if (isMounted && g.__klyroAppendDelta) {
|
|
195
|
-
g.__klyroAppendDelta(ev.text);
|
|
196
|
-
// Also keep coalescing for fallback inline mode
|
|
197
|
-
if (!pendingTextId)
|
|
198
|
-
pendingTextId = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// Fallback inline mode: coalesce via queuedAppend
|
|
202
|
-
if (pendingTextId) {
|
|
203
|
-
const last = pendingQueue[pendingQueue.length - 1];
|
|
204
|
-
if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
|
|
205
|
-
last.item.text += ev.text;
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (pendingTextId && isMounted) {
|
|
210
|
-
queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
214
|
-
pendingTextId = id;
|
|
215
|
-
queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
|
|
197
|
+
// single appendDelta path — App merges into one assistant item (Q→A order, no duplication)
|
|
198
|
+
queuedDelta(ev.text);
|
|
216
199
|
}
|
|
217
200
|
else if (ev.kind === 'verification_started') {
|
|
218
201
|
queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: `[verify] running \`${ev.command}\``, role: 'assistant' });
|
|
@@ -272,16 +255,7 @@ export async function startRepl(opts = {}) {
|
|
|
272
255
|
});
|
|
273
256
|
}
|
|
274
257
|
else if (ev.kind === 'final_text') {
|
|
275
|
-
//
|
|
276
|
-
const g2 = globalThis;
|
|
277
|
-
g2.__klyroCommitLive?.();
|
|
278
|
-
pendingTextId = null;
|
|
279
|
-
// For full-screen, also ensure liveText is committed if any remaining
|
|
280
|
-
if (ev.text) {
|
|
281
|
-
const g3 = globalThis;
|
|
282
|
-
// If liveText was batched, ensure it's flushed and committed
|
|
283
|
-
g2.__klyroCommitLive?.();
|
|
284
|
-
}
|
|
258
|
+
// streamingId is closed by status change; no extra handling needed
|
|
285
259
|
}
|
|
286
260
|
else if (ev.kind === 'usage') {
|
|
287
261
|
queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Klyro
|
|
3
|
-
*
|
|
2
|
+
* Klyro TUI — opencode-style linear transcript
|
|
3
|
+
* Header (top) → Conversation (scrollable, Q→A→Q→A) → Input (bottom) → StatusBar (bottom)
|
|
4
|
+
* Single streamingId merges text_delta into one assistant item — no duplication, no liveText ghost.
|
|
4
5
|
*/
|
|
5
6
|
import React from 'react';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import type { StatusSnapshot } from './status.js';
|
|
8
|
+
import type { TranscriptItem } from './transcript.js';
|
|
8
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
10
|
import type { PlanStep } from '../agent/runtime.js';
|
|
10
11
|
export interface AppProps {
|
|
@@ -18,6 +19,7 @@ export interface AppProps {
|
|
|
18
19
|
approvalBridge?: TuiApprovalBridge;
|
|
19
20
|
onMounted?: (hooks: {
|
|
20
21
|
append: (i: TranscriptItem) => void;
|
|
22
|
+
appendDelta: (text: string) => void;
|
|
21
23
|
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
|
22
24
|
updatePlan: (p: PlanStep[]) => void;
|
|
23
25
|
}) => void;
|
package/dist/tui/app.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* Klyro
|
|
4
|
-
*
|
|
3
|
+
* Klyro TUI — opencode-style linear transcript
|
|
4
|
+
* Header (top) → Conversation (scrollable, Q→A→Q→A) → Input (bottom) → StatusBar (bottom)
|
|
5
|
+
* Single streamingId merges text_delta into one assistant item — no duplication, no liveText ghost.
|
|
5
6
|
*/
|
|
6
7
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
8
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
|
-
import { Transcript } from './transcript.js';
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
10
10
|
import { PlanView } from './plan.js';
|
|
11
11
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
@@ -31,41 +31,17 @@ export function App(props) {
|
|
|
31
31
|
});
|
|
32
32
|
const [elapsed, setElapsed] = useState(0);
|
|
33
33
|
const [queued, setQueued] = useState(null);
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const batchTimer = useRef(null);
|
|
37
|
-
const flushBatch = useCallback(() => {
|
|
38
|
-
if (batchRef.current) {
|
|
39
|
-
const chunk = batchRef.current;
|
|
40
|
-
batchRef.current = '';
|
|
41
|
-
setLiveText((prev) => prev + chunk);
|
|
42
|
-
}
|
|
43
|
-
if (batchTimer.current) {
|
|
44
|
-
clearTimeout(batchTimer.current);
|
|
45
|
-
batchTimer.current = null;
|
|
46
|
-
}
|
|
47
|
-
}, []);
|
|
48
|
-
const appendDeltaBatched = useCallback((text) => {
|
|
49
|
-
batchRef.current += text;
|
|
50
|
-
if (!batchTimer.current)
|
|
51
|
-
batchTimer.current = setTimeout(flushBatch, 33);
|
|
52
|
-
}, [flushBatch]);
|
|
53
|
-
const commitLive = useCallback(() => {
|
|
54
|
-
flushBatch();
|
|
55
|
-
if (liveText) {
|
|
56
|
-
const item = { id: nextId('text'), kind: 'text', text: liveText, role: 'assistant' };
|
|
57
|
-
setTranscript((prev) => [...prev, item]);
|
|
58
|
-
setLiveText('');
|
|
59
|
-
}
|
|
60
|
-
}, [liveText, flushBatch]);
|
|
34
|
+
// streaming: one assistant text item that text_delta merges into
|
|
35
|
+
const streamingIdRef = useRef(null);
|
|
61
36
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
62
|
-
//
|
|
37
|
+
// queued: send when idle (2.4)
|
|
63
38
|
useEffect(() => {
|
|
64
39
|
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
65
40
|
const toSend = queued;
|
|
66
41
|
setQueued(null);
|
|
67
|
-
const item = { id: nextId('
|
|
42
|
+
const item = { id: nextId('user'), kind: 'text', text: toSend, role: 'user' };
|
|
68
43
|
setTranscript((prev) => [...prev, item]);
|
|
44
|
+
streamingIdRef.current = null;
|
|
69
45
|
const cmd = parseSlash(toSend.trim());
|
|
70
46
|
if (cmd.kind === 'prompt')
|
|
71
47
|
void props.onPrompt(cmd.text);
|
|
@@ -81,34 +57,56 @@ export function App(props) {
|
|
|
81
57
|
return () => clearInterval(t);
|
|
82
58
|
}, [status.status, elapsed]);
|
|
83
59
|
const append = useCallback((item) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
60
|
+
// any non-streaming append closes the current streaming block
|
|
61
|
+
if (item.kind !== 'text' || item.role !== 'assistant')
|
|
62
|
+
streamingIdRef.current = null;
|
|
63
|
+
setTranscript((prev) => [...prev, item]);
|
|
64
|
+
}, []);
|
|
65
|
+
const appendDelta = useCallback((text) => {
|
|
66
|
+
if (!text)
|
|
67
|
+
return;
|
|
68
|
+
const sid = streamingIdRef.current;
|
|
69
|
+
if (sid) {
|
|
70
|
+
setTranscript((prev) => {
|
|
71
|
+
const idx = prev.findIndex((x) => x.id === sid);
|
|
72
|
+
if (idx === -1)
|
|
73
|
+
return [...prev, { id: sid, kind: 'text', text, role: 'assistant' }];
|
|
74
|
+
const cur = prev[idx];
|
|
75
|
+
const next = { ...cur, text: cur.text + text };
|
|
76
|
+
const copy = [...prev];
|
|
77
|
+
copy[idx] = next;
|
|
78
|
+
return copy;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const id = nextId('stream');
|
|
83
|
+
streamingIdRef.current = id;
|
|
84
|
+
setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
|
|
85
|
+
}
|
|
91
86
|
}, []);
|
|
87
|
+
// close streaming block when status leaves running (so next text_delta starts new item)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (status.status !== 'running')
|
|
90
|
+
streamingIdRef.current = null;
|
|
91
|
+
}, [status.status]);
|
|
92
92
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
93
93
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
94
94
|
const onMountedRef = useRef(props.onMounted);
|
|
95
95
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
96
96
|
useEffect(() => {
|
|
97
|
-
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
97
|
+
onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan });
|
|
98
|
+
// global hooks for repl bridge (instance-local queue drains here)
|
|
98
99
|
globalThis.__klyroAppAppend = append;
|
|
100
|
+
globalThis.__klyroAppendDelta = appendDelta;
|
|
99
101
|
globalThis.__klyroAppStatus = updateStatus;
|
|
100
102
|
globalThis.__klyroAppPlan = updatePlan;
|
|
101
|
-
globalThis.__klyroAppendDelta = appendDeltaBatched;
|
|
102
|
-
globalThis.__klyroCommitLive = commitLive;
|
|
103
103
|
return () => {
|
|
104
104
|
delete globalThis.__klyroAppAppend;
|
|
105
|
+
delete globalThis.__klyroAppendDelta;
|
|
105
106
|
delete globalThis.__klyroAppStatus;
|
|
106
107
|
delete globalThis.__klyroAppPlan;
|
|
107
|
-
delete globalThis.__klyroAppendDelta;
|
|
108
|
-
delete globalThis.__klyroCommitLive;
|
|
109
108
|
};
|
|
110
|
-
}, [append, updateStatus, updatePlan
|
|
111
|
-
// Single useInput owner — handles queued when running (2.4)
|
|
109
|
+
}, [append, appendDelta, updateStatus, updatePlan]);
|
|
112
110
|
useInput((inputStr, key) => {
|
|
113
111
|
if (awaitingApproval)
|
|
114
112
|
return;
|
|
@@ -123,7 +121,8 @@ export function App(props) {
|
|
|
123
121
|
return;
|
|
124
122
|
setQueued(v);
|
|
125
123
|
setInput('');
|
|
126
|
-
|
|
124
|
+
// queued indicator as muted text, not a full user bubble (opencode style)
|
|
125
|
+
setTranscript((prev) => [...prev, { id: nextId('queued'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
|
|
127
126
|
return;
|
|
128
127
|
}
|
|
129
128
|
if (key.backspace || key.delete) {
|
|
@@ -141,8 +140,9 @@ export function App(props) {
|
|
|
141
140
|
if (!v)
|
|
142
141
|
return;
|
|
143
142
|
setInput('');
|
|
144
|
-
const item = { id: nextId('
|
|
143
|
+
const item = { id: nextId('user'), kind: 'text', text: v, role: 'user' };
|
|
145
144
|
setTranscript((prev) => [...prev, item]);
|
|
145
|
+
streamingIdRef.current = null;
|
|
146
146
|
const cmd = parseSlash(v);
|
|
147
147
|
if (cmd.kind === 'prompt')
|
|
148
148
|
void props.onPrompt(cmd.text);
|
|
@@ -160,5 +160,5 @@ export function App(props) {
|
|
|
160
160
|
const width = stdout?.columns ?? 100;
|
|
161
161
|
const height = stdout?.rows ?? 30;
|
|
162
162
|
const isSmall = width < 80;
|
|
163
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.
|
|
163
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.16" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"hi\" or /help" })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 300) }) : null] })) : item.kind === 'diff' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: item.summary ?? 'Diff' }), item.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.info, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.success : l.kind === 'remove' ? tokens.ansi.error : tokens.ansi.muted, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] })) : item.kind === 'error' ? (_jsxs(Text, { color: tokens.ansi.error, children: ["[error] ", item.message] })) : item.kind === 'policy' ? (_jsxs(Text, { color: tokens.ansi.muted, children: ["[policy] ", item.action, " ", item.name, item.reason ? ` — ${item.reason}` : ''] })) : item.kind === 'file_changed' ? (_jsxs(Text, { color: tokens.ansi.muted, children: ["[", item.op, "] ", item.path] })) : null }, item.id)))), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
|
|
164
164
|
}
|