klyro 0.1.57 → 0.1.59

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/tui/app.js CHANGED
@@ -153,6 +153,7 @@ function useChatScroll(opts) {
153
153
  pageDown: () => dispatch({ type: 'BY_HALF_PAGE', dir: 1 }),
154
154
  jumpTop: () => dispatch({ type: 'TO_TOP' }),
155
155
  jumpBottom: () => dispatch({ type: 'TO_BOTTOM' }),
156
+ reset: () => dispatch({ type: 'TO_BOTTOM' }),
156
157
  }), [dispatch]);
157
158
  const resolved = resolveTopRow(state, ctx);
158
159
  const maxTop = maxTopFor(ctx);
@@ -216,6 +217,7 @@ export function App(props) {
216
217
  bottom: () => { },
217
218
  halfPage: (_dir) => { },
218
219
  top: () => { },
220
+ reset: () => { },
219
221
  });
220
222
  const width = stdout?.columns ?? 100;
221
223
  const height = stdout?.rows ?? 30;
@@ -357,6 +359,7 @@ export function App(props) {
357
359
  }
358
360
  },
359
361
  bottom: () => commands.jumpBottom(),
362
+ reset: () => commands.reset(),
360
363
  halfPage: (dir) => {
361
364
  if (dir < 0)
362
365
  commands.pageUp();
@@ -416,10 +419,15 @@ export function App(props) {
416
419
  thinkingIdRef.current = null;
417
420
  setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
418
421
  }, []);
419
- useEffect(() => { if (status.status !== 'running') {
420
- streamingIdRef.current = null;
421
- thinkingIdRef.current = null;
422
- } }, [status.status]);
422
+ useEffect(() => {
423
+ if (status.status !== 'running') {
424
+ streamingIdRef.current = null;
425
+ thinkingIdRef.current = null;
426
+ // Runs that end without final_text (abort/cancel/error) must not leave
427
+ // stale thinking blocks behind — only the response may remain.
428
+ setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
429
+ }
430
+ }, [status.status]);
423
431
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
424
432
  const updatePlan = useCallback((p) => setPlan(p), []);
425
433
  // Tool results patch the running start-item IN PLACE (no second item, so a
@@ -436,7 +444,15 @@ export function App(props) {
436
444
  return copy;
437
445
  });
438
446
  }, []);
439
- const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
447
+ const clearTranscript = useCallback(() => {
448
+ streamingIdRef.current = null;
449
+ thinkingIdRef.current = null;
450
+ setTranscript([]);
451
+ setPlan([]);
452
+ // Fresh content → fresh scroll (a pruned anchor would otherwise stick to
453
+ // the bottom with a stale newSinceUnstick badge count).
454
+ scrollCmdsRef.current.reset();
455
+ }, []);
440
456
  // Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
441
457
  // Stored in refs so the callbacks stay stable while acting on latest state.
442
458
  const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
@@ -751,5 +767,5 @@ export function App(props) {
751
767
  if (it.kind === 'diff')
752
768
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
753
769
  return null;
754
- }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
770
+ }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
755
771
  }
@@ -518,6 +518,53 @@ describe('App', () => {
518
518
  expect(frame).not.toContain('{"path"');
519
519
  expect(frame).not.toContain('queued:');
520
520
  });
521
+ it('aborted run leaves no thinking behind (only response may remain)', async () => {
522
+ let hooks = null;
523
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialStatus: { status: 'running' }, onMounted: (h) => {
524
+ hooks = { appendThinkingDelta: h.appendThinkingDelta, updateStatus: h.updateStatus };
525
+ } }));
526
+ await new Promise((r) => setTimeout(r, 50));
527
+ hooks.appendThinkingDelta('half-formed thought...');
528
+ await waitForMatch(lastFrame, /half-formed thought/);
529
+ // Abort (no final_text): thinking must be cleaned up, not linger.
530
+ hooks.updateStatus({ status: 'aborted' });
531
+ await waitForAbsent(lastFrame, /half-formed thought/);
532
+ });
533
+ it('clearTranscript resets scroll state (no stale badge count)', async () => {
534
+ let hooks = null;
535
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
536
+ hooks = { append: h.append, clearTranscript: h.clearTranscript };
537
+ } }));
538
+ await new Promise((r) => setTimeout(r, 100));
539
+ stdin.write(KEY_HOME);
540
+ await new Promise((r) => setTimeout(r, 50));
541
+ hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
542
+ // Badge counts lines grown while pinned.
543
+ await waitForMatch(lastFrame, /↓ \d+ new/);
544
+ hooks.clearTranscript();
545
+ await new Promise((r) => setTimeout(r, 50));
546
+ // Fresh session: seed 25 more, pin, grow by one 2-line item → badge is exactly 2.
547
+ for (let i = 0; i < 25; i++) {
548
+ hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
549
+ }
550
+ await new Promise((r) => setTimeout(r, 100));
551
+ stdin.write(KEY_HOME);
552
+ await new Promise((r) => setTimeout(r, 50));
553
+ hooks.append({ id: 'n-late', kind: 'text', text: 'NEWLATE-tag', role: 'assistant' });
554
+ // Assistant block = header + text + margin = 3 fresh lines, no stale count.
555
+ const badge = await waitForMatch(lastFrame, /↓ \d+ new/);
556
+ expect(badge).toMatch(/↓ 3 new/);
557
+ });
558
+ it('status bar shows scroll position when content overflows', async () => {
559
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
560
+ await new Promise((r) => setTimeout(r, 100));
561
+ // At bottom: topRow == maxTop, indicator shows position.
562
+ expect(lastFrame() ?? '').toMatch(/⇅ \d+\/\d+/);
563
+ // Pinned to top: position shows 0/N.
564
+ stdin.write(KEY_HOME);
565
+ const top = await waitForMatch(lastFrame, /⇅ 0\/\d+/);
566
+ expect(top).toMatch(/MSG-00-tag/);
567
+ });
521
568
  it('Shift+Up / Shift+Down scroll by one line', async () => {
522
569
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
523
570
  await new Promise((r) => setTimeout(r, 50));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
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",