klyro 0.1.50 → 0.1.51

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.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { StatusSnapshot } from './status.js';
7
7
  import type { TranscriptItem } from './transcript.js';
8
8
  import { TuiApprovalBridge } from './approval.js';
9
9
  import type { PlanStep } from '../agent/runtime.js';
10
+ import { type TranscriptScrollHandle } from './transcript-commands.js';
10
11
  export interface AppProps {
11
12
  initialModel: string;
12
13
  maxSteps: number;
@@ -26,6 +27,7 @@ export interface AppProps {
26
27
  scrollToBottom: () => void;
27
28
  scrollHalfPage: (dir: -1 | 1) => void;
28
29
  scrollToTop: () => void;
30
+ transcript: TranscriptScrollHandle;
29
31
  }) => void;
30
32
  version?: string;
31
33
  isFullscreen?: boolean;
package/dist/tui/app.js CHANGED
@@ -12,6 +12,7 @@ import { tokens, g } from './tokens.js';
12
12
  import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
13
13
  import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
14
14
  import { renderMarkdownLines } from './markdown.js';
15
+ import { getTranscriptCommand, } from './transcript-commands.js';
15
16
  let _id = 0;
16
17
  function nextId(p) { _id++; return `${p}-${_id}`; }
17
18
  function Header({ cwd, model, version, width }) {
@@ -184,13 +185,21 @@ export function App(props) {
184
185
  if (status.status !== 'running')
185
186
  ctrlCArmed.current = false;
186
187
  }, [status.status]);
187
- // design.md §13: submit snaps to bottom immediate + microtask + timeout
188
- // so React/Ink layout settles before the final pin.
189
- const snapToBottomSettled = useCallback(() => {
190
- scrollCmdsRef.current.bottom();
191
- queueMicrotask(() => scrollCmdsRef.current.bottom());
192
- setTimeout(() => scrollCmdsRef.current.bottom(), 0);
193
- }, []);
188
+ // scroll.md §7: submit bumps a version key; the settle effect below pins
189
+ // to bottom immediate + microtask + timeout so layout settles first.
190
+ // (Decoupled from render: never scroll directly inside the submit handler.)
191
+ const [submitKey, setSubmitKey] = useState(0);
192
+ useEffect(() => {
193
+ if (!submitKey)
194
+ return;
195
+ const toBottom = () => {
196
+ scrollCmdsRef.current.bottom();
197
+ };
198
+ toBottom();
199
+ queueMicrotask(toBottom);
200
+ const t = setTimeout(toBottom, 0);
201
+ return () => clearTimeout(t);
202
+ }, [submitKey]);
194
203
  // Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
195
204
  // ESC+CR pair, or Esc immediately followed by Return (75ms, non-empty input).
196
205
  const escReturnAt = useRef(0);
@@ -383,9 +392,28 @@ export function App(props) {
383
392
  const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
384
393
  const scrollHalfPage = useCallback((dir) => { scrollCmdsRef.current.halfPage(dir); }, []);
385
394
  const scrollToTop = useCallback(() => { scrollCmdsRef.current.top(); }, []);
395
+ // scroll.md §2/§10: the four-command TranscriptScrollHandle.
396
+ const transcriptHandle = useMemo(() => ({
397
+ runTranscriptCommand: (command) => {
398
+ switch (command) {
399
+ case 'messages_half_page_up':
400
+ scrollCmdsRef.current.halfPage(-1);
401
+ return;
402
+ case 'messages_half_page_down':
403
+ scrollCmdsRef.current.halfPage(1);
404
+ return;
405
+ case 'messages_first':
406
+ scrollCmdsRef.current.top();
407
+ return;
408
+ case 'messages_last':
409
+ scrollCmdsRef.current.bottom();
410
+ return;
411
+ }
412
+ },
413
+ }), []);
386
414
  const onMountedRef = useRef(props.onMounted);
387
415
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
388
- useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop }); 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]);
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]);
389
417
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
390
418
  n.delete(id);
391
419
  else
@@ -406,26 +434,32 @@ export function App(props) {
406
434
  return;
407
435
  }
408
436
  // Scroll keys (work in any mode, including while running).
437
+ // scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
409
438
  // design.md §11: PageUp/Ctrl+U half-up, PageDown/Ctrl+D half-down,
410
- // Ctrl+Home/Ctrl+End first/last. (Ctrl+B/F kept as pager aliases.)
439
+ // Ctrl+Home/Ctrl+End first/last. (Plain Home/End + Ctrl+B/F/G kept.)
411
440
  if (isFullscreen && maxTop > 0) {
441
+ const tcmd = getTranscriptCommand(inputStr, key);
442
+ if (tcmd) {
443
+ transcriptHandle.runTranscriptCommand(tcmd);
444
+ return;
445
+ }
412
446
  if (key.home) {
413
447
  commands.jumpTop();
414
448
  return;
415
- } // Home / Ctrl+Home → first
449
+ }
416
450
  if (key.end) {
417
451
  commands.jumpBottom();
418
452
  return;
419
- } // End / Ctrl+End → last
453
+ }
420
454
  if (key.ctrl && inputStr === 'g') {
421
455
  commands.jumpBottom();
422
456
  return;
423
457
  } // Ctrl+G → bottom
424
- if (key.pageUp || (key.ctrl && inputStr === 'u') || (key.ctrl && inputStr === 'b')) {
458
+ if ((key.ctrl && inputStr === 'b')) {
425
459
  commands.pageUp();
426
460
  return;
427
461
  }
428
- if (key.pageDown || (key.ctrl && inputStr === 'd') || (key.ctrl && inputStr === 'f')) {
462
+ if ((key.ctrl && inputStr === 'f')) {
429
463
  commands.pageDown();
430
464
  return;
431
465
  }
@@ -490,7 +524,7 @@ export function App(props) {
490
524
  setQueuedInputs((prev) => [...prev, v]);
491
525
  setInput('');
492
526
  pushHistory(v);
493
- snapToBottomSettled();
527
+ setSubmitKey((k) => k + 1);
494
528
  return;
495
529
  }
496
530
  if (key.backspace || key.delete) {
@@ -555,7 +589,7 @@ export function App(props) {
555
589
  pushHistory(v);
556
590
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
557
591
  streamingIdRef.current = null;
558
- snapToBottomSettled();
592
+ setSubmitKey((k) => k + 1);
559
593
  const cmd = parseSlash(v);
560
594
  if (cmd.kind === 'prompt')
561
595
  void props.onPrompt(cmd.text);
@@ -654,5 +688,5 @@ export function App(props) {
654
688
  if (it.kind === 'diff')
655
689
  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));
656
690
  return null;
657
- }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _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, 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, 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, _jsxs(Box, { flexDirection: "column", 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", children: [_jsxs(Text, { color: tokens.colors.dim, children: [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' ? ' ●' : ''] })] })] }));
691
+ }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _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, 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, 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, _jsxs(Box, { flexDirection: "column", 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", children: [_jsxs(Text, { color: tokens.colors.dim, children: [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' ? ' ●' : ''] })] })] }));
658
692
  }
@@ -319,6 +319,43 @@ describe('App', () => {
319
319
  await new Promise((r) => setTimeout(r, 50));
320
320
  expect(onPrompt).toHaveBeenCalledWith('line one\nline two');
321
321
  });
322
+ it('submitting while pinned snaps back to bottom (scroll.md §7)', async () => {
323
+ const onPrompt = vi.fn(async () => { });
324
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
325
+ await new Promise((r) => setTimeout(r, 100));
326
+ stdin.write(KEY_HOME);
327
+ await new Promise((r) => setTimeout(r, 50));
328
+ expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
329
+ stdin.write('hello again');
330
+ await new Promise((r) => setTimeout(r, 20));
331
+ stdin.write('\x0d');
332
+ // Settle effect: immediate + microtask + timeout(0) bottom pin.
333
+ await new Promise((r) => setTimeout(r, 150));
334
+ expect(onPrompt).toHaveBeenCalledWith('hello again');
335
+ expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
336
+ });
337
+ it('onMounted transcript handle runs the four commands (scroll.md §2)', async () => {
338
+ let handle = null;
339
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
340
+ handle = h.transcript;
341
+ } }));
342
+ await new Promise((r) => setTimeout(r, 50));
343
+ expect(handle).not.toBeNull();
344
+ handle.runTranscriptCommand('messages_half_page_up');
345
+ await new Promise((r) => setTimeout(r, 50));
346
+ let frame = lastFrame() ?? '';
347
+ // Half page (10 lines) up from bottom (row 30 → 20): MSG-24 gone, MSG-14 in view.
348
+ expect(frame).not.toMatch(/MSG-24-tag/);
349
+ expect(frame).toMatch(/MSG-14-tag/);
350
+ handle.runTranscriptCommand('messages_first');
351
+ await new Promise((r) => setTimeout(r, 50));
352
+ frame = lastFrame() ?? '';
353
+ expect(frame).toMatch(/MSG-00-tag/);
354
+ expect(frame).not.toMatch(/MSG-24-tag/);
355
+ handle.runTranscriptCommand('messages_last');
356
+ await new Promise((r) => setTimeout(r, 50));
357
+ expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
358
+ });
322
359
  it('Shift+Up / Shift+Down scroll by one line', async () => {
323
360
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
324
361
  await new Promise((r) => setTimeout(r, 50));
@@ -0,0 +1,34 @@
1
+ /**
2
+ * scroll.md §§2/4/5/6 — TranscriptCommand abstraction.
3
+ *
4
+ * Only four scroll commands exist (Cline parity):
5
+ * messages_half_page_up / messages_half_page_down /
6
+ * messages_first / messages_last
7
+ *
8
+ * Flow (§6): Keyboard → getTranscriptCommand() → TranscriptScrollHandle →
9
+ * ChatMessageList. Keys are matched in ONE place (the §5 binding table);
10
+ * the App routes everything else (history, autocomplete, approvals).
11
+ *
12
+ * Note on the stack: the doc's `<scrollbox stickyScroll>` is OpenTUI-only
13
+ * and this app renders with Ink, which has no scrollbox primitive — so the
14
+ * handle drives the measured anchor viewport (tui/scroll-model.ts) instead.
15
+ * The command vocabulary and all §21 behaviors are identical.
16
+ */
17
+ export type TranscriptCommand = 'messages_half_page_up' | 'messages_half_page_down' | 'messages_first' | 'messages_last';
18
+ export interface TranscriptScrollHandle {
19
+ runTranscriptCommand(command: TranscriptCommand): void;
20
+ }
21
+ /** Minimal Ink key shape needed for the binding table. */
22
+ export interface TranscriptKey {
23
+ pageUp?: boolean;
24
+ pageDown?: boolean;
25
+ home?: boolean;
26
+ end?: boolean;
27
+ ctrl?: boolean;
28
+ }
29
+ /**
30
+ * scroll.md §5 binding table (pure — unit-tested):
31
+ * PageUp / Ctrl+U → up · PageDown / Ctrl+D → down ·
32
+ * Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
33
+ */
34
+ export declare function getTranscriptCommand(input: string, key: TranscriptKey): TranscriptCommand | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * scroll.md §§2/4/5/6 — TranscriptCommand abstraction.
3
+ *
4
+ * Only four scroll commands exist (Cline parity):
5
+ * messages_half_page_up / messages_half_page_down /
6
+ * messages_first / messages_last
7
+ *
8
+ * Flow (§6): Keyboard → getTranscriptCommand() → TranscriptScrollHandle →
9
+ * ChatMessageList. Keys are matched in ONE place (the §5 binding table);
10
+ * the App routes everything else (history, autocomplete, approvals).
11
+ *
12
+ * Note on the stack: the doc's `<scrollbox stickyScroll>` is OpenTUI-only
13
+ * and this app renders with Ink, which has no scrollbox primitive — so the
14
+ * handle drives the measured anchor viewport (tui/scroll-model.ts) instead.
15
+ * The command vocabulary and all §21 behaviors are identical.
16
+ */
17
+ /**
18
+ * scroll.md §5 binding table (pure — unit-tested):
19
+ * PageUp / Ctrl+U → up · PageDown / Ctrl+D → down ·
20
+ * Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
21
+ */
22
+ export function getTranscriptCommand(input, key) {
23
+ if (key.pageUp || (key.ctrl && input === 'u'))
24
+ return 'messages_half_page_up';
25
+ if (key.pageDown || (key.ctrl && input === 'd'))
26
+ return 'messages_half_page_down';
27
+ if (key.home && key.ctrl)
28
+ return 'messages_first';
29
+ if (key.end && key.ctrl)
30
+ return 'messages_last';
31
+ return undefined;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
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",