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 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
- queuedAppend({
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 each line.
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(_Fragment, { children: lines.map((l, i) => (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: 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))) }));
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: { kind: 'group', count: gr.items.length, expanded: expandedGroups.has(gr.id) },
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
- const baseHints = 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';
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
- return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : 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) => {
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);
@@ -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));
@@ -33,6 +33,8 @@ export type BlockDesc = {
33
33
  kind: 'group';
34
34
  count: number;
35
35
  expanded: boolean;
36
+ status: string;
37
+ resultLen: number;
36
38
  } | {
37
39
  kind: 'error';
38
40
  message: string;
@@ -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
  {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",