klyro 0.1.40 → 0.1.41

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
@@ -3,7 +3,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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 } from 'react';
6
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
7
7
  import { Box, Text, useInput, useStdout } from 'ink';
8
8
  import { TuiApprovalBridge } from './approval.js';
9
9
  import { parse as parseSlash } from '../cli/slash/parser.js';
@@ -92,6 +92,79 @@ function MarkdownText({ text, dim, width }) {
92
92
  // Render as single line with bold segments — Ink will wrap the parent Box
93
93
  return _jsx(Text, { wrap: "wrap", children: parts });
94
94
  }
95
+ // Chat scroll state: scrollOffset, pinned (user scrolled away from bottom),
96
+ // pendingNew (rows arrived while pinned), and a commands bag for key handlers.
97
+ // The `tick` prop is a monotonic value that increments on every content mutation,
98
+ // including in-place text growth during streaming (appendDelta mutates by index,
99
+ // so transcript.length does not change on a delta — the effect must fire anyway).
100
+ function useChatScroll(opts) {
101
+ const { totalRows, viewportH, messageBoundaries, tick } = opts;
102
+ const [scrollOffset, setScrollOffset] = useState(0);
103
+ const [pinned, setPinned] = useState(false);
104
+ const [pendingNew, setPendingNew] = useState(0);
105
+ const pinnedRef = useRef(false);
106
+ const maxOffset = Math.max(0, totalRows - viewportH);
107
+ // 1-line tolerance: maxOffset can shift by 1 during streaming and leave us
108
+ // at maxOffset - 1, which would otherwise be "not at bottom". The +1 tolerance
109
+ // keeps follow-tail engaged through that off-by-one.
110
+ const isAtBottom = scrollOffset + 1 >= maxOffset;
111
+ const recomputePinned = useCallback((next) => {
112
+ const atBottom = next + 1 >= maxOffset;
113
+ pinnedRef.current = !atBottom;
114
+ setPinned(!atBottom);
115
+ if (atBottom)
116
+ setPendingNew(0);
117
+ }, [maxOffset]);
118
+ // Watch `tick` — fires on every content change (add, remove, in-place delta).
119
+ const lastTickRef = useRef(tick);
120
+ const lastMaxOffsetRef = useRef(maxOffset);
121
+ const firstEffectRef = useRef(true);
122
+ useEffect(() => {
123
+ if (firstEffectRef.current) {
124
+ // Initial mount: if there's content, follow the tail (preserves the
125
+ // pre-refactor behavior where scrollOffset was 0 only on empty state).
126
+ firstEffectRef.current = false;
127
+ lastTickRef.current = tick;
128
+ lastMaxOffsetRef.current = maxOffset;
129
+ if (maxOffset > 0) {
130
+ setScrollOffset(maxOffset);
131
+ }
132
+ return;
133
+ }
134
+ if (tick === lastTickRef.current)
135
+ return;
136
+ lastTickRef.current = tick;
137
+ const grew = maxOffset - lastMaxOffsetRef.current;
138
+ lastMaxOffsetRef.current = maxOffset;
139
+ if (pinnedRef.current) {
140
+ if (grew > 0)
141
+ setPendingNew((p) => p + grew);
142
+ }
143
+ else {
144
+ // FollowTail: snap to the new bottom.
145
+ setScrollOffset(maxOffset);
146
+ }
147
+ }, [tick, maxOffset]);
148
+ const commands = {
149
+ lineUp: () => { const next = Math.max(0, scrollOffset - 1); setScrollOffset(next); recomputePinned(next); },
150
+ lineDown: () => { const next = Math.min(maxOffset, scrollOffset + 1); setScrollOffset(next); recomputePinned(next); },
151
+ pageUp: () => {
152
+ const prev = [...messageBoundaries].reverse().find((b) => b < scrollOffset);
153
+ const next = prev ?? Math.max(0, scrollOffset - viewportH);
154
+ setScrollOffset(next);
155
+ recomputePinned(next);
156
+ },
157
+ pageDown: () => {
158
+ const nxt = messageBoundaries.find((b) => b > scrollOffset);
159
+ const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
160
+ setScrollOffset(next);
161
+ recomputePinned(next);
162
+ },
163
+ jumpTop: () => { setScrollOffset(0); recomputePinned(0); },
164
+ jumpBottom: () => { setScrollOffset(maxOffset); recomputePinned(maxOffset); },
165
+ };
166
+ return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
167
+ }
95
168
  export function App(props) {
96
169
  const { stdout } = useStdout();
97
170
  const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
@@ -103,7 +176,6 @@ export function App(props) {
103
176
  const [elapsed, setElapsed] = useState(0);
104
177
  const [queuedInputs, setQueuedInputs] = useState([]);
105
178
  const [expandedGroups, setExpandedGroups] = useState(new Set());
106
- const [scrollOffset, setScrollOffset] = useState(0);
107
179
  const streamingIdRef = useRef(null);
108
180
  const width = stdout?.columns ?? 100;
109
181
  const height = stdout?.rows ?? 30;
@@ -111,8 +183,18 @@ export function App(props) {
111
183
  const grouped = groupTools(transcript);
112
184
  const viewportH = Math.max(5, height - 10);
113
185
  const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
114
- const maxOffset = Math.max(0, totalRows - viewportH);
115
- const isAtBottom = scrollOffset >= maxOffset;
186
+ const messageBoundaries = useMemo(() => grouped.map((_, i) => i), [grouped]);
187
+ // Monotonic tick: increments on every render, so the scroll hook fires
188
+ // for every content mutation — including in-place text deltas.
189
+ const tickRef = useRef(0);
190
+ useEffect(() => { tickRef.current += 1; });
191
+ const scroll = useChatScroll({
192
+ totalRows,
193
+ viewportH,
194
+ messageBoundaries,
195
+ tick: tickRef.current,
196
+ });
197
+ const { scrollOffset, isAtBottom, maxOffset, pinned, pendingNew, commands } = scroll;
116
198
  const trackH = viewportH;
117
199
  const thumbPos = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
118
200
  const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
@@ -132,8 +214,6 @@ export function App(props) {
132
214
  }, [queuedInputs, status.status, awaitingApproval]);
133
215
  useEffect(() => { if (status.status !== 'running')
134
216
  return; const start = Date.now() - elapsed; const t = setInterval(() => setElapsed(Date.now() - start), 1000); return () => clearInterval(t); }, [status.status, elapsed]);
135
- useEffect(() => { if (isAtBottom)
136
- setScrollOffset(maxOffset); }, [transcript.length, plan.length, maxOffset, isAtBottom]);
137
217
  const append = useCallback((item) => { if (item.kind !== 'text' || item.role !== 'assistant')
138
218
  streamingIdRef.current = null; setTranscript((prev) => [...prev, item]); }, []);
139
219
  const appendDelta = useCallback((text) => {
@@ -160,28 +240,37 @@ export function App(props) {
160
240
  n.delete(id);
161
241
  else
162
242
  n.add(id); return n; });
163
- const scrollUp = (n = 3) => setScrollOffset((p) => Math.max(0, p - n));
164
- const scrollDown = (n = 3) => setScrollOffset((p) => Math.min(maxOffset, p + n));
165
243
  useInput((inputStr, key) => {
166
244
  if (key.escape && queuedInputs.length > 0) {
167
245
  setQueuedInputs((prev) => prev.slice(1));
168
246
  return;
169
247
  }
170
- if (key.pageUp || (key.ctrl && inputStr === 'u')) {
171
- scrollUp(5);
172
- return;
173
- }
174
- if (key.pageDown || (key.ctrl && inputStr === 'd')) {
175
- scrollDown(5);
176
- return;
177
- }
178
- if (key.upArrow && (key.shift || key.ctrl)) {
179
- scrollUp(1);
180
- return;
181
- }
182
- if (key.downArrow && (key.shift || key.ctrl)) {
183
- scrollDown(1);
184
- return;
248
+ // Scroll keys (work in any mode, including while running).
249
+ if (isFullscreen && maxOffset > 0) {
250
+ if (key.home) {
251
+ commands.jumpTop();
252
+ return;
253
+ }
254
+ if (key.end) {
255
+ commands.jumpBottom();
256
+ return;
257
+ }
258
+ if (key.pageUp || (key.ctrl && inputStr === 'u')) {
259
+ commands.pageUp();
260
+ return;
261
+ }
262
+ if (key.pageDown || (key.ctrl && inputStr === 'd')) {
263
+ commands.pageDown();
264
+ return;
265
+ }
266
+ if (key.upArrow && (key.shift || key.ctrl)) {
267
+ commands.lineUp();
268
+ return;
269
+ }
270
+ if (key.downArrow && (key.shift || key.ctrl)) {
271
+ commands.lineDown();
272
+ return;
273
+ }
185
274
  }
186
275
  if (awaitingApproval)
187
276
  return;
@@ -317,5 +406,5 @@ export function App(props) {
317
406
  if (it.kind === 'diff')
318
407
  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));
319
408
  return null;
320
- }), 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, 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) => (_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'), " ", p.title] })] }, p.id)))] })) : null, 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] }), _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\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _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, maxOffset > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
409
+ }), 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, 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) => (_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'), " ", p.title] })] }, p.id)))] })) : null, 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, ' new ', pendingNew === 1 ? 'message' : 'messages', ' '] }) })) : 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\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _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, maxOffset > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
321
410
  }
@@ -79,4 +79,146 @@ describe('App', () => {
79
79
  const call = onSlash.mock.calls[0]?.[0];
80
80
  expect(call?.kind).toBe('quit');
81
81
  });
82
+ // --- Chat scroll behavior (TUI_DESIGN chat_scroll.md) -----------------
83
+ // Build a 25-item initial transcript. Each item has a unique tag so we can
84
+ // grep `lastFrame()` for it.
85
+ function makeInitialTranscript(n) {
86
+ const out = [];
87
+ for (let i = 0; i < n; i++) {
88
+ out.push({
89
+ id: `seed-${i}`,
90
+ kind: 'text',
91
+ text: `MSG-${i.toString().padStart(2, '0')}-tag`,
92
+ role: 'user',
93
+ });
94
+ }
95
+ return out;
96
+ }
97
+ // ANSI sequences Ink's parse-keypress recognizes.
98
+ const KEY_HOME = '\x1b[H';
99
+ const KEY_END = '\x1b[F';
100
+ const KEY_PGUP = '\x1b[5~';
101
+ const KEY_PGDN = '\x1b[6~';
102
+ const KEY_SHIFT_UP = '\x1b[1;2A';
103
+ const KEY_SHIFT_DOWN = '\x1b[1;2B';
104
+ it('starts at the bottom (follow-tail) when initial content fills the viewport', async () => {
105
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
106
+ await new Promise((r) => setTimeout(r, 50));
107
+ const frame = lastFrame() ?? '';
108
+ // The viewport is 20 rows; the last few seeded items (MSG-22..MSG-24) should
109
+ // be in the visible window. The first item (MSG-00) should NOT be visible.
110
+ expect(frame).toMatch(/MSG-24-tag/);
111
+ expect(frame).toMatch(/MSG-23-tag/);
112
+ expect(frame).not.toMatch(/MSG-00-tag/);
113
+ });
114
+ it('Home jumps to the top; End re-engages follow-tail', async () => {
115
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
116
+ await new Promise((r) => setTimeout(r, 50));
117
+ stdin.write(KEY_HOME);
118
+ await new Promise((r) => setTimeout(r, 30));
119
+ const top = lastFrame() ?? '';
120
+ expect(top).toMatch(/MSG-00-tag/);
121
+ expect(top).not.toMatch(/MSG-24-tag/);
122
+ // End re-engages follow-tail.
123
+ stdin.write(KEY_END);
124
+ await new Promise((r) => setTimeout(r, 30));
125
+ const bottom = lastFrame() ?? '';
126
+ expect(bottom).toMatch(/MSG-24-tag/);
127
+ expect(bottom).not.toMatch(/MSG-00-tag/);
128
+ });
129
+ it('PageUp/PageDown snap to message boundaries', async () => {
130
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
131
+ await new Promise((r) => setTimeout(r, 50));
132
+ // Go to top, then PageDown 3 times. Each PageDown should land on a message
133
+ // boundary, so visible window starts at one of the seeded indices.
134
+ stdin.write(KEY_HOME);
135
+ await new Promise((r) => setTimeout(r, 30));
136
+ stdin.write(KEY_PGDN);
137
+ await new Promise((r) => setTimeout(r, 30));
138
+ stdin.write(KEY_PGDN);
139
+ await new Promise((r) => setTimeout(r, 30));
140
+ stdin.write(KEY_PGDN);
141
+ await new Promise((r) => setTimeout(r, 30));
142
+ const frame = lastFrame() ?? '';
143
+ // After 3 PageDowns from top, the earliest visible item should be MSG-03
144
+ // (snap-to-message keeps the boundary on the first visible row). We assert
145
+ // that MSG-03 is visible and MSG-00 is not.
146
+ expect(frame).toMatch(/MSG-03-tag/);
147
+ expect(frame).not.toMatch(/MSG-00-tag/);
148
+ });
149
+ it('pins to top: new content does NOT auto-scroll when user has scrolled up', async () => {
150
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
151
+ await new Promise((r) => setTimeout(r, 100));
152
+ // Pin: scroll up to top.
153
+ stdin.write(KEY_HOME);
154
+ await new Promise((r) => setTimeout(r, 100));
155
+ const before = lastFrame() ?? '';
156
+ expect(before).toMatch(/MSG-00-tag/);
157
+ expect(before).not.toMatch(/MSG-24-tag/);
158
+ // New content arrives while pinned.
159
+ const g = globalThis;
160
+ g.__klyroAppAppend({
161
+ id: 'late-1',
162
+ kind: 'text',
163
+ text: 'LATE-1-tag',
164
+ role: 'assistant',
165
+ });
166
+ g.__klyroAppAppend({
167
+ id: 'late-2',
168
+ kind: 'text',
169
+ text: 'LATE-2-tag',
170
+ role: 'assistant',
171
+ });
172
+ g.__klyroAppAppend({
173
+ id: 'late-3',
174
+ kind: 'text',
175
+ text: 'LATE-3-tag',
176
+ role: 'assistant',
177
+ });
178
+ await new Promise((r) => setTimeout(r, 200));
179
+ const after = lastFrame() ?? '';
180
+ // Still pinned at top: MSG-00 visible, LATE items not in viewport.
181
+ expect(after).toMatch(/MSG-00-tag/);
182
+ expect(after).not.toMatch(/LATE-1-tag/);
183
+ });
184
+ it('pressing End re-engages follow-tail and reveals new content', async () => {
185
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
186
+ await new Promise((r) => setTimeout(r, 100));
187
+ stdin.write(KEY_HOME);
188
+ await new Promise((r) => setTimeout(r, 100));
189
+ const g = globalThis;
190
+ g.__klyroAppAppend({
191
+ id: 'late-1',
192
+ kind: 'text',
193
+ text: 'LATE-1-tag',
194
+ role: 'assistant',
195
+ });
196
+ await new Promise((r) => setTimeout(r, 200));
197
+ expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
198
+ // End re-engages follow-tail and shows the new content.
199
+ stdin.write(KEY_END);
200
+ await new Promise((r) => setTimeout(r, 100));
201
+ const frame = lastFrame() ?? '';
202
+ expect(frame).toMatch(/LATE-1-tag/);
203
+ });
204
+ it('Shift+Up / Shift+Down scroll by one line', async () => {
205
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
206
+ await new Promise((r) => setTimeout(r, 50));
207
+ // Jump to top, then shift+down a few times, then back with shift+up.
208
+ stdin.write(KEY_HOME);
209
+ await new Promise((r) => setTimeout(r, 30));
210
+ stdin.write(KEY_SHIFT_DOWN);
211
+ stdin.write(KEY_SHIFT_DOWN);
212
+ await new Promise((r) => setTimeout(r, 30));
213
+ const frame = lastFrame() ?? '';
214
+ // Shift+Down from scrollOffset=0 moves us down by 2. The visible window
215
+ // is now [2..22). MSG-00 should be off-screen, MSG-02 should be on-screen.
216
+ expect(frame).not.toMatch(/MSG-00-tag/);
217
+ expect(frame).toMatch(/MSG-02-tag/);
218
+ // Shift+Up once: scrollOffset back to 1, MSG-01 visible, MSG-02 still visible.
219
+ stdin.write(KEY_SHIFT_UP);
220
+ await new Promise((r) => setTimeout(r, 30));
221
+ const frame2 = lastFrame() ?? '';
222
+ expect(frame2).toMatch(/MSG-01-tag/);
223
+ });
82
224
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
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",