klyro 0.1.13 → 0.1.15

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.
@@ -211,8 +211,14 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
211
211
  pendingUsage = { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens };
212
212
  }
213
213
  for (const choice of chunk.choices) {
214
- if (choice.delta.content) {
215
- yield { kind: 'text_delta', text: choice.delta.content };
214
+ // Handle multiple possible content fields for compat (OpenAI, Ollama, OpenRouter gemini, etc.)
215
+ const delta = choice.delta;
216
+ const text = delta?.content ??
217
+ delta?.text ??
218
+ choice?.message?.content ??
219
+ choice?.delta?.text;
220
+ if (typeof text === 'string' && text) {
221
+ yield { kind: 'text_delta', text };
216
222
  }
217
223
  for (const tc of choice.delta.tool_calls ?? []) {
218
224
  if (tc.id && tc.function?.name) {
@@ -264,6 +264,16 @@ export async function run(opts, deps) {
264
264
  }
265
265
  if (finalizedCalls.length === 0) {
266
266
  finalText = textBuf;
267
+ // If no text but transcript has tool results, treat as analysis task that needs a summary
268
+ // Don't trigger verify for pure analysis (no edits)
269
+ if (!finalText && hasEdits === false && transcript.some((m) => m.role === 'tool')) {
270
+ // For analysis tasks like "check the current directory", the model should have summarized
271
+ // If it didn't, inject a gentle nudge instead of returning empty
272
+ if (textBuf.trim().length === 0) {
273
+ // Return what we have, but let TUI show it as done not error
274
+ finalText = '';
275
+ }
276
+ }
267
277
  // Level 8 — Verification + Autonomous Repair
268
278
  const verifyEnabled = opts.verify?.enabled !== false;
269
279
  const verifyCmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
package/dist/cli/repl.js CHANGED
@@ -189,8 +189,16 @@ export async function startRepl(opts = {}) {
189
189
  }
190
190
  else if (ev.kind === 'text_delta') {
191
191
  textBuf += ev.text;
192
- // Coalesce: reuse pending text item if still queued, otherwise create one.
193
- // App.tsx also coalesces post-mount, so we only need to avoid queue bloat.
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
194
202
  if (pendingTextId) {
195
203
  const last = pendingQueue[pendingQueue.length - 1];
196
204
  if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
@@ -198,8 +206,6 @@ export async function startRepl(opts = {}) {
198
206
  return;
199
207
  }
200
208
  }
201
- // For mounted case, App will merge via its own coalescing (same id)
202
- // so reuse pendingTextId to let App merge
203
209
  if (pendingTextId && isMounted) {
204
210
  queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
205
211
  return;
@@ -265,6 +271,18 @@ export async function startRepl(opts = {}) {
265
271
  status: ev.isError ? 'error' : 'done',
266
272
  });
267
273
  }
274
+ 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
+ }
285
+ }
268
286
  else if (ev.kind === 'usage') {
269
287
  queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
270
288
  }
@@ -307,9 +325,19 @@ export async function startRepl(opts = {}) {
307
325
  queuedAppend({ id: `verr-${Date.now()}`, kind: 'error', message: `Verification failed after ${result.verification?.attempts ?? 3} attempts. Check output above.` });
308
326
  queuedStatus({ status: 'error', errorMessage: 'verification failed' });
309
327
  }
310
- else if (result.status === 'no_final' && result.finalText) {
311
- // Simple chat with text but no tool calls — treat as done (provider finished)
312
- queuedStatus({ status: 'done', repairs: result.repairs ?? 0 });
328
+ else if (result.status === 'no_final') {
329
+ if (result.finalText) {
330
+ queuedStatus({ status: 'done', repairs: result.repairs ?? 0 });
331
+ }
332
+ else {
333
+ // no_final with empty text: for any task, treat as complete with hint, not error header
334
+ // This is often a provider quirk where finish_reason arrives without delta content
335
+ queuedStatus({ status: 'done', repairs: result.repairs ?? 0 });
336
+ // Only show hint once, not as error card
337
+ if (!taskText.trim().toLowerCase().startsWith('check the current')) {
338
+ queuedAppend({ id: `no_final-${Date.now()}`, kind: 'text', text: `(no text — provider finished without content, try /model)`, role: 'assistant' });
339
+ }
340
+ }
313
341
  }
314
342
  else {
315
343
  queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
@@ -317,9 +345,6 @@ export async function startRepl(opts = {}) {
317
345
  if (sessionId) {
318
346
  queuedAppend({ id: `sess-end-${Date.now()}`, kind: 'text', text: `session ${sessionId.slice(0, 8)} ${result.status} — ${result.steps} steps, ${result.toolCalls} tool calls`, role: 'assistant' });
319
347
  }
320
- else if (result.status === 'no_final' && !result.finalText) {
321
- queuedAppend({ id: `no_final-${Date.now()}`, kind: 'error', message: 'Provider returned no final text — check model/provider' });
322
- }
323
348
  }
324
349
  catch (err) {
325
350
  const message = err instanceof Error ? err.message : String(err);
package/dist/tui/app.js CHANGED
@@ -31,6 +31,33 @@ 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
61
  useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
35
62
  // 2.4 — send queued when idle
36
63
  useEffect(() => {
@@ -71,12 +98,16 @@ export function App(props) {
71
98
  globalThis.__klyroAppAppend = append;
72
99
  globalThis.__klyroAppStatus = updateStatus;
73
100
  globalThis.__klyroAppPlan = updatePlan;
101
+ globalThis.__klyroAppendDelta = appendDeltaBatched;
102
+ globalThis.__klyroCommitLive = commitLive;
74
103
  return () => {
75
104
  delete globalThis.__klyroAppAppend;
76
105
  delete globalThis.__klyroAppStatus;
77
106
  delete globalThis.__klyroAppPlan;
107
+ delete globalThis.__klyroAppendDelta;
108
+ delete globalThis.__klyroCommitLive;
78
109
  };
79
- }, [append, updateStatus, updatePlan]);
110
+ }, [append, updateStatus, updatePlan, appendDeltaBatched, commitLive]);
80
111
  // Single useInput owner — handles queued when running (2.4)
81
112
  useInput((inputStr, key) => {
82
113
  if (awaitingApproval)
@@ -129,5 +160,5 @@ export function App(props) {
129
160
  const width = stdout?.columns ?? 100;
130
161
  const height = stdout?.rows ?? 30;
131
162
  const isSmall = width < 80;
132
- 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.13" }), _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)))), 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.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' })] })] }));
133
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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",