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 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
- textBuf += ev.text;
192
- // Try full-screen live region first (batched 30fps)
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
- // Commit live region for both inline and full-screen TUI
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 Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
3
- * Full viewport, conversation, input, status bar professional, dense, terminal-native
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 { type StatusSnapshot } from './status.js';
7
- import { type TranscriptItem } from './transcript.js';
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 Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
4
- * Full viewport, conversation, input, status bar professional, dense, terminal-native
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
- const [liveText, setLiveText] = useState('');
35
- const batchRef = useRef('');
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
- // 2.4 send queued when idle
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('text'), kind: 'text', text: toSend, role: 'user' };
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
- setTranscript((prev) => {
85
- const last = prev[prev.length - 1];
86
- if (last?.kind === 'text' && item.kind === 'text' && last.role === 'assistant' && item.role === 'assistant' && last.id === item.id) {
87
- return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
88
- }
89
- return [...prev, item];
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, appendDeltaBatched, commitLive]);
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
- setTranscript((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
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('text'), kind: 'text', text: v, role: 'user' };
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.15" }), _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(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (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, 200) }) : 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)))] })) : (_jsx(Transcript, { items: [item] })) }, item.id)))), liveText ? (_jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { children: [liveText, "\u258D"] }) })) : status.status === 'running' ? (_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' })] })] }));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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",