klyro 0.1.50 → 0.1.52

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
@@ -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';
@@ -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 }) {
@@ -82,13 +83,17 @@ function groupTools(items) {
82
83
  return out;
83
84
  }
84
85
  // design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
85
- // **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.
86
91
  function MarkdownText({ text, dim, width }) {
87
92
  void width;
88
93
  const lines = useMemo(() => renderMarkdownLines(text), [text]);
89
94
  const dimColor = tokens.colors.dim;
90
95
  const softColor = tokens.colors.soft;
91
- 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))) }));
92
97
  }
93
98
  // Chat scroll — scroll.md §5 anchor model adapted to Ink.
94
99
  //
@@ -184,13 +189,21 @@ export function App(props) {
184
189
  if (status.status !== 'running')
185
190
  ctrlCArmed.current = false;
186
191
  }, [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
- }, []);
192
+ // scroll.md §7: submit bumps a version key; the settle effect below pins
193
+ // to bottom immediate + microtask + timeout so layout settles first.
194
+ // (Decoupled from render: never scroll directly inside the submit handler.)
195
+ const [submitKey, setSubmitKey] = useState(0);
196
+ useEffect(() => {
197
+ if (!submitKey)
198
+ return;
199
+ const toBottom = () => {
200
+ scrollCmdsRef.current.bottom();
201
+ };
202
+ toBottom();
203
+ queueMicrotask(toBottom);
204
+ const t = setTimeout(toBottom, 0);
205
+ return () => clearTimeout(t);
206
+ }, [submitKey]);
194
207
  // Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
195
208
  // ESC+CR pair, or Esc immediately followed by Return (75ms, non-empty input).
196
209
  const escReturnAt = useRef(0);
@@ -383,9 +396,28 @@ export function App(props) {
383
396
  const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
384
397
  const scrollHalfPage = useCallback((dir) => { scrollCmdsRef.current.halfPage(dir); }, []);
385
398
  const scrollToTop = useCallback(() => { scrollCmdsRef.current.top(); }, []);
399
+ // scroll.md §2/§10: the four-command TranscriptScrollHandle.
400
+ const transcriptHandle = useMemo(() => ({
401
+ runTranscriptCommand: (command) => {
402
+ switch (command) {
403
+ case 'messages_half_page_up':
404
+ scrollCmdsRef.current.halfPage(-1);
405
+ return;
406
+ case 'messages_half_page_down':
407
+ scrollCmdsRef.current.halfPage(1);
408
+ return;
409
+ case 'messages_first':
410
+ scrollCmdsRef.current.top();
411
+ return;
412
+ case 'messages_last':
413
+ scrollCmdsRef.current.bottom();
414
+ return;
415
+ }
416
+ },
417
+ }), []);
386
418
  const onMountedRef = useRef(props.onMounted);
387
419
  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]);
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]);
389
421
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
390
422
  n.delete(id);
391
423
  else
@@ -406,26 +438,32 @@ export function App(props) {
406
438
  return;
407
439
  }
408
440
  // Scroll keys (work in any mode, including while running).
441
+ // scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
409
442
  // 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.)
443
+ // Ctrl+Home/Ctrl+End first/last. (Plain Home/End + Ctrl+B/F/G kept.)
411
444
  if (isFullscreen && maxTop > 0) {
445
+ const tcmd = getTranscriptCommand(inputStr, key);
446
+ if (tcmd) {
447
+ transcriptHandle.runTranscriptCommand(tcmd);
448
+ return;
449
+ }
412
450
  if (key.home) {
413
451
  commands.jumpTop();
414
452
  return;
415
- } // Home / Ctrl+Home → first
453
+ }
416
454
  if (key.end) {
417
455
  commands.jumpBottom();
418
456
  return;
419
- } // End / Ctrl+End → last
457
+ }
420
458
  if (key.ctrl && inputStr === 'g') {
421
459
  commands.jumpBottom();
422
460
  return;
423
461
  } // Ctrl+G → bottom
424
- if (key.pageUp || (key.ctrl && inputStr === 'u') || (key.ctrl && inputStr === 'b')) {
462
+ if ((key.ctrl && inputStr === 'b')) {
425
463
  commands.pageUp();
426
464
  return;
427
465
  }
428
- if (key.pageDown || (key.ctrl && inputStr === 'd') || (key.ctrl && inputStr === 'f')) {
466
+ if ((key.ctrl && inputStr === 'f')) {
429
467
  commands.pageDown();
430
468
  return;
431
469
  }
@@ -490,7 +528,7 @@ export function App(props) {
490
528
  setQueuedInputs((prev) => [...prev, v]);
491
529
  setInput('');
492
530
  pushHistory(v);
493
- snapToBottomSettled();
531
+ setSubmitKey((k) => k + 1);
494
532
  return;
495
533
  }
496
534
  if (key.backspace || key.delete) {
@@ -555,7 +593,7 @@ export function App(props) {
555
593
  pushHistory(v);
556
594
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
557
595
  streamingIdRef.current = null;
558
- snapToBottomSettled();
596
+ setSubmitKey((k) => k + 1);
559
597
  const cmd = parseSlash(v);
560
598
  if (cmd.kind === 'prompt')
561
599
  void props.onPrompt(cmd.text);
@@ -654,5 +692,5 @@ export function App(props) {
654
692
  if (it.kind === 'diff')
655
693
  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
694
  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' ? ' ●' : ''] })] })] }));
695
+ }) : 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
696
  }
@@ -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));
@@ -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
  {
@@ -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.52",
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",