klyro 0.1.52 → 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
@@ -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';
@@ -238,7 +238,13 @@ export function App(props) {
238
238
  const gr = entry;
239
239
  out.push({
240
240
  key: gr.id,
241
- 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
+ },
242
248
  groupIndex: gi,
243
249
  tail: null,
244
250
  });
@@ -389,6 +395,20 @@ export function App(props) {
389
395
  streamingIdRef.current = null; }, [status.status]);
390
396
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
391
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
+ }, []);
392
412
  const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
393
413
  // Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
394
414
  // Stored in refs so the callbacks stay stable while acting on latest state.
@@ -417,7 +437,7 @@ export function App(props) {
417
437
  }), []);
418
438
  const onMountedRef = useRef(props.onMounted);
419
439
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
420
- 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]);
421
441
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
422
442
  n.delete(id);
423
443
  else
@@ -616,9 +636,15 @@ export function App(props) {
616
636
  const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
617
637
  const totalTokens = status.usageInput + status.usageOutput;
618
638
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
619
- 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';
620
643
  const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
621
- 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) => {
622
648
  if (item.verb) {
623
649
  const gr = item;
624
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':
@@ -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.52",
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",