klyro 1.0.3 → 1.0.4

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
@@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process';
10
10
  import { TuiApprovalBridge, ApprovalModal } from './approval.js';
11
11
  import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
12
12
  import { tokens, g } from './tokens.js';
13
- import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
13
+ import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, badgeLabel, } from './scroll-model.js';
14
14
  import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
15
15
  import { renderMarkdownLines } from './markdown.js';
16
16
  import { readVersion } from '../version.js';
@@ -322,7 +322,7 @@ export function App(props) {
322
322
  const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
323
323
  const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
324
324
  const scroll = useChatScroll({ keys: blockKeys, heights: blockHeights, viewportH, width });
325
- const { topRow, maxTop, pinned, pendingNew, commands } = scroll;
325
+ const { topRow, maxTop, pinned, pendingNew, commands, atBottom } = scroll;
326
326
  const trackH = viewportH;
327
327
  const thumbPos = maxTop === 0 ? 0 : Math.round((topRow / maxTop) * (trackH - 1));
328
328
  // Slice *display lines* [topRow, topRow+viewportH): intersecting blocks only
@@ -538,6 +538,10 @@ export function App(props) {
538
538
  commands.jumpBottom();
539
539
  return;
540
540
  }
541
+ if (inputStr === ' ' && pinned && pendingNew > 0) {
542
+ commands.jumpBottom();
543
+ return;
544
+ } // Space → jump to unread tail
541
545
  if (key.ctrl && inputStr === 'g') {
542
546
  commands.jumpBottom();
543
547
  return;
@@ -788,5 +792,5 @@ export function App(props) {
788
792
  if (it.kind === 'diff')
789
793
  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));
790
794
  return null;
791
- }) : 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' ? ' ●' : ''] })] })] }));
795
+ }) : 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 && !atBottom ? (_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 && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : 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' ? ' ●' : ''] })] })] }));
792
796
  }
@@ -541,13 +541,13 @@ describe('App', () => {
541
541
  await waitForMatch(lastFrame, /⇅ 0\/\d+/);
542
542
  hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
543
543
  // Badge counts lines grown while pinned.
544
- await waitForMatch(lastFrame, /↓ \d+ new/);
544
+ await waitForMatch(lastFrame, /↓ \d+ unread/);
545
545
  hooks.clearTranscript();
546
546
  // Wait for transcript content to actually clear (no stale MSG-* items) —
547
547
  // this is the "no stale badge" invariant: after a clear, the view must
548
548
  // drop back to follow-tail with the pin/count reset.
549
549
  await waitForAbsent(lastFrame, /MSG-\d{2}-tag/);
550
- await waitForAbsent(lastFrame, /↓ \d+ new/);
550
+ await waitForAbsent(lastFrame, /↓ \d+ unread/);
551
551
  // Fresh session: seed 25 more, let them settle to a bottom-anchored view.
552
552
  for (let i = 0; i < 25; i++) {
553
553
  hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
@@ -558,7 +558,7 @@ describe('App', () => {
558
558
  await waitForMatch(lastFrame, /NEWLATE-tag/);
559
559
  await new Promise((r) => setTimeout(r, 100));
560
560
  const afterFollow = lastFrame() ?? '';
561
- expect(afterFollow).not.toMatch(/↓ \d+ new/);
561
+ expect(afterFollow).not.toMatch(/↓ \d+ unread/);
562
562
  expect(afterFollow).not.toMatch(/MSG-\d{2}-tag/);
563
563
  });
564
564
  it('status bar shows scroll position when content overflows', async () => {
@@ -66,7 +66,7 @@ describe('scroll flow diagnostics', () => {
66
66
  }
67
67
  const frozen = lastFrame() ?? '';
68
68
  expect(frozen).toContain('MSG-00-tag'); // viewport did not yank down
69
- expect(frozen).toMatch(/↓ \d+ new/); // badge visible
69
+ expect(frozen).toMatch(/↓ \d+ unread/); // badge visible
70
70
  expect(rowsOf(frozen)).toBeLessThanOrEqual(32);
71
71
  stdin.write('\x1b[F'); // End → follow
72
72
  await tick(50);
@@ -63,5 +63,7 @@ export interface Resolved {
63
63
  }
64
64
  /** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
65
65
  export declare function resolveTopRow(s: ScrollState, ctx: ScrollCtx): Resolved;
66
- /** Badge label per §7.2. Empty string = hidden. */
66
+ /** Badge label per §7.2 (Claude Code style: "N unread" / "jump to end").
67
+ * Empty string = hidden. `idle` = agent done streaming, the only new lines
68
+ * are the ones already counted — the affordance flips to "jump to end". */
67
69
  export declare function badgeLabel(atBottom: boolean, newSinceUnstick: number, idle: boolean): string;
@@ -87,13 +87,14 @@ export function resolveTopRow(s, ctx) {
87
87
  const topRow = clampN(raw, 0, maxTop);
88
88
  return { topRow, atBottom: topRow >= maxTop - FOLLOW_EPSILON };
89
89
  }
90
- /** Badge label per §7.2. Empty string = hidden. */
90
+ /** Badge label per §7.2 (Claude Code style: "N unread" / "jump to end").
91
+ * Empty string = hidden. `idle` = agent done streaming, the only new lines
92
+ * are the ones already counted — the affordance flips to "jump to end". */
91
93
  export function badgeLabel(atBottom, newSinceUnstick, idle) {
92
94
  if (atBottom || newSinceUnstick <= 0)
93
95
  return '';
94
- if (idle)
95
- return '↓ jump to end';
96
+ void idle; // Ink can't capture the idle click; count is shown either way (§7.2)
96
97
  if (newSinceUnstick >= 1000)
97
- return '↓ 999+ new';
98
- return `↓ ${newSinceUnstick} new`;
98
+ return '↓ 999+ unread';
99
+ return `↓ ${newSinceUnstick} unread`;
99
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
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",