klyro 0.1.46 → 0.1.47

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/cli/repl.js CHANGED
@@ -19,6 +19,7 @@ import { TuiApprovalBridge } from '../tui/approval.js';
19
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
20
20
  import { parse } from './slash/parser.js';
21
21
  import { resolveProvider, providerHelp } from '../providers.js';
22
+ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE } from '../tui/mouse.js';
22
23
  import { inferProviderFromBaseURL } from '../agent/registry.js';
23
24
  import { getDefaultSessionStore } from '../persistence/session.js';
24
25
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
@@ -139,6 +140,7 @@ export async function startRepl(opts = {}) {
139
140
  try {
140
141
  process.stdout.write('\x1b[?1049h\x1b[?25l'); // alt screen + hide cursor
141
142
  process.stdout.write('\x1b[H\x1b[2J'); // home + clear
143
+ process.stdout.write(MOUSE_ENABLE); // wheel events (SGR), see tui/mouse.ts
142
144
  }
143
145
  catch { /* ignore */ }
144
146
  };
@@ -146,10 +148,45 @@ export async function startRepl(opts = {}) {
146
148
  if (!isAltScreen)
147
149
  return;
148
150
  try {
151
+ process.stdout.write(MOUSE_DISABLE);
149
152
  process.stdout.write('\x1b[?25h\x1b[?1049l'); // show cursor + leave alt
150
153
  }
151
154
  catch { /* ignore */ }
152
155
  };
156
+ // OpenCode-style wheel scrolling (§8.5, S8): Ink owns stdin and cannot see
157
+ // mouse events, so tap stdin.emit — wheel deltas drive the App's scroll
158
+ // hooks, everything else passes through to Ink untouched.
159
+ const mouseFilter = new MouseFilter();
160
+ const origStdinEmit = process.stdin.emit.bind(process.stdin);
161
+ let mouseTapInstalled = false;
162
+ function installMouseTap() {
163
+ if (!isAltScreen || mouseTapInstalled)
164
+ return;
165
+ mouseTapInstalled = true;
166
+ process.stdin.emit = (...a) => {
167
+ if (a[0] === 'data' && Buffer.isBuffer(a[1])) {
168
+ const split = mouseFilter.push(a[1]);
169
+ for (const w of split.wheels) {
170
+ try {
171
+ if (isMounted && directHooks)
172
+ directHooks.scrollLines(w);
173
+ }
174
+ catch { /* ignore */ }
175
+ }
176
+ if (split.kept.length === 0)
177
+ return false;
178
+ a[1] = split.kept;
179
+ }
180
+ return origStdinEmit(...a);
181
+ };
182
+ }
183
+ function removeMouseTap() {
184
+ if (!mouseTapInstalled)
185
+ return;
186
+ mouseTapInstalled = false;
187
+ mouseFilter.reset();
188
+ process.stdin.emit = origStdinEmit;
189
+ }
153
190
  // Declare app before handler to avoid TDZ; handler added after render
154
191
  let app;
155
192
  let sigintHandler;
@@ -201,6 +238,7 @@ export async function startRepl(opts = {}) {
201
238
  console.debug = origConsoleFns.debug;
202
239
  }
203
240
  patchConsole();
241
+ installMouseTap();
204
242
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
205
243
  // P1 session/permission state (commands.md Priority 1)
206
244
  let sessionLabel = '';
@@ -2159,6 +2197,7 @@ export async function startRepl(opts = {}) {
2159
2197
  process.removeListener('SIGTERM', sigintHandler);
2160
2198
  }
2161
2199
  restoreConsole();
2200
+ removeMouseTap();
2162
2201
  leaveAlt();
2163
2202
  // §1.2 exit behavior: replay a plain-text transcript into the main
2164
2203
  // buffer so the session survives in native scrollback.
package/dist/tui/app.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface AppProps {
22
22
  updateStatus: (s: Partial<StatusSnapshot>) => void;
23
23
  updatePlan: (p: PlanStep[]) => void;
24
24
  clearTranscript: () => void;
25
+ scrollLines: (delta: number) => void;
26
+ scrollToBottom: () => void;
25
27
  }) => void;
26
28
  version?: string;
27
29
  isFullscreen?: boolean;
package/dist/tui/app.js CHANGED
@@ -201,6 +201,8 @@ export function App(props) {
201
201
  setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
202
202
  setHistIdx(null);
203
203
  }, []);
204
+ // Live scroll control for external drivers (mouse-wheel tap in repl.ts).
205
+ const scrollCmdsRef = useRef({ line: (_d) => { }, bottom: () => { } });
204
206
  const width = stdout?.columns ?? 100;
205
207
  const height = stdout?.rows ?? 30;
206
208
  const isFullscreen = props.isFullscreen ?? false;
@@ -318,6 +320,19 @@ export function App(props) {
318
320
  }
319
321
  }
320
322
  const visibleGrouped = isFullscreen && !tiny ? grouped.slice(gi0, gi1 + 1) : grouped;
323
+ // Publish live scroll control for the mouse-wheel tap (stable callbacks, latest ctx).
324
+ scrollCmdsRef.current = {
325
+ line: (d) => {
326
+ const n = Math.abs(Math.round(d));
327
+ for (let i = 0; i < n; i++) {
328
+ if (d < 0)
329
+ commands.lineUp();
330
+ else
331
+ commands.lineDown();
332
+ }
333
+ },
334
+ bottom: () => commands.jumpBottom(),
335
+ };
321
336
  useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
322
337
  useEffect(() => {
323
338
  if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
@@ -354,9 +369,13 @@ export function App(props) {
354
369
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
355
370
  const updatePlan = useCallback((p) => setPlan(p), []);
356
371
  const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
372
+ // Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
373
+ // Stored in refs so the callbacks stay stable while acting on latest state.
374
+ const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
375
+ const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
357
376
  const onMountedRef = useRef(props.onMounted);
358
377
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
359
- useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript }); 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]);
378
+ useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom }); 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]);
360
379
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
361
380
  n.delete(id);
362
381
  else
@@ -390,11 +409,11 @@ export function App(props) {
390
409
  commands.jumpBottom();
391
410
  return;
392
411
  } // Ctrl+G → bottom (§8.4)
393
- if (key.pageUp || (key.ctrl && inputStr === 'u')) {
412
+ if (key.pageUp || (key.ctrl && inputStr === 'u') || (key.ctrl && inputStr === 'b')) {
394
413
  commands.pageUp();
395
414
  return;
396
415
  }
397
- if (key.pageDown || (key.ctrl && inputStr === 'd')) {
416
+ if (key.pageDown || (key.ctrl && inputStr === 'd') || (key.ctrl && inputStr === 'f')) {
398
417
  commands.pageDown();
399
418
  return;
400
419
  }
@@ -249,6 +249,25 @@ describe('App', () => {
249
249
  await new Promise((r) => setTimeout(r, 100));
250
250
  expect(lastFrame() ?? '').toMatch(/LATE-1-tag/);
251
251
  });
252
+ it('onMounted scrollLines/scrollToBottom drive the viewport (wheel path)', async () => {
253
+ let captured = null;
254
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
255
+ captured = { scrollLines: h.scrollLines, scrollToBottom: h.scrollToBottom };
256
+ } }));
257
+ await new Promise((r) => setTimeout(r, 50));
258
+ expect(captured).not.toBeNull();
259
+ // Wheel up ×12 (3 lines each = 36 > maxTop 32) → pinned at top.
260
+ for (let i = 0; i < 12; i++)
261
+ captured.scrollLines(-3);
262
+ await new Promise((r) => setTimeout(r, 50));
263
+ const top = lastFrame() ?? '';
264
+ expect(top).toMatch(/MSG-00-tag/);
265
+ expect(top).not.toMatch(/MSG-24-tag/);
266
+ // scrollToBottom → tail visible again.
267
+ captured.scrollToBottom();
268
+ await new Promise((r) => setTimeout(r, 50));
269
+ expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
270
+ });
252
271
  it('Shift+Up / Shift+Down scroll by one line', async () => {
253
272
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
254
273
  await new Promise((r) => setTimeout(r, 50));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * scroll.md §8.5 (S8) — mouse wheel capture for OpenCode-style scrolling.
3
+ *
4
+ * Ink owns stdin via useInput and cannot see mouse events, so the REPL wraps
5
+ * `process.stdin.emit` with this stateful splitter: SGR/X10 mouse sequences
6
+ * are swallowed (wheel → scroll deltas, clicks/motion → dropped), everything
7
+ * else passes through to Ink untouched.
8
+ *
9
+ * Only the wheel is acted on. Clicks are swallowed (not forwarded) because a
10
+ * mouse-reporting terminal would otherwise deliver them to readline as typed
11
+ * garbage; Shift+drag still selects natively in most terminals.
12
+ *
13
+ * Sequences handled:
14
+ * SGR: `\x1b[<Cb;x;yM` / `...m` (1006, requested via `CSI ? 1006 h`)
15
+ * X10: `\x1b[M Cb Cx Cy` (fallback for terminals ignoring 1006)
16
+ * Wheel bit is 64 in both; direction bit is 1 (down) — modifiers OR into Cb.
17
+ */
18
+ export declare const WHEEL_LINES = 3;
19
+ export interface MouseSplit {
20
+ /** bytes Ink is still allowed to see */
21
+ kept: Buffer;
22
+ /** wheel deltas: negative = up (older), positive = down (newer) */
23
+ wheels: number[];
24
+ }
25
+ export declare class MouseFilter {
26
+ private pending;
27
+ /**
28
+ * Split one stdin chunk. Holds an unambiguous trailing partial mouse
29
+ * sequence for the next chunk; a lone trailing ESC passes through
30
+ * immediately so the Esc key (queued-drop) never lags.
31
+ */
32
+ push(chunk: Buffer): MouseSplit;
33
+ reset(): void;
34
+ }
35
+ export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
36
+ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
@@ -0,0 +1,94 @@
1
+ /**
2
+ * scroll.md §8.5 (S8) — mouse wheel capture for OpenCode-style scrolling.
3
+ *
4
+ * Ink owns stdin via useInput and cannot see mouse events, so the REPL wraps
5
+ * `process.stdin.emit` with this stateful splitter: SGR/X10 mouse sequences
6
+ * are swallowed (wheel → scroll deltas, clicks/motion → dropped), everything
7
+ * else passes through to Ink untouched.
8
+ *
9
+ * Only the wheel is acted on. Clicks are swallowed (not forwarded) because a
10
+ * mouse-reporting terminal would otherwise deliver them to readline as typed
11
+ * garbage; Shift+drag still selects natively in most terminals.
12
+ *
13
+ * Sequences handled:
14
+ * SGR: `\x1b[<Cb;x;yM` / `...m` (1006, requested via `CSI ? 1006 h`)
15
+ * X10: `\x1b[M Cb Cx Cy` (fallback for terminals ignoring 1006)
16
+ * Wheel bit is 64 in both; direction bit is 1 (down) — modifiers OR into Cb.
17
+ */
18
+ export const WHEEL_LINES = 3; // scroll.md §8.4: wheel = ±3 lines
19
+ function isDigit(b) {
20
+ return b >= 0x30 && b <= 0x39;
21
+ }
22
+ export class MouseFilter {
23
+ pending = Buffer.alloc(0);
24
+ /**
25
+ * Split one stdin chunk. Holds an unambiguous trailing partial mouse
26
+ * sequence for the next chunk; a lone trailing ESC passes through
27
+ * immediately so the Esc key (queued-drop) never lags.
28
+ */
29
+ push(chunk) {
30
+ const buf = Buffer.concat([this.pending, chunk]);
31
+ this.pending = Buffer.alloc(0);
32
+ const kept = [];
33
+ const wheels = [];
34
+ let i = 0;
35
+ const n = buf.length;
36
+ while (i < n) {
37
+ // SGR mouse: ESC [ < Cb ; x ; y (M|m)
38
+ if (buf[i] === 0x1b && i + 2 < n && buf[i + 1] === 0x5b && buf[i + 2] === 0x3c) {
39
+ let j = i + 3;
40
+ while (j < n && (isDigit(buf[j]) || buf[j] === 0x3b))
41
+ j++;
42
+ if (j >= n) {
43
+ // split across chunks — hold for more data
44
+ this.pending = buf.subarray(i);
45
+ break;
46
+ }
47
+ const term = buf[j];
48
+ if (term === 0x4d || term === 0x6d) {
49
+ const cb = parseInt(buf.subarray(i + 3, j).toString().split(';')[0] ?? 'NaN', 10);
50
+ if (!Number.isNaN(cb) && (cb & 64) !== 0) {
51
+ wheels.push((cb & 1) === 0 ? -WHEEL_LINES : WHEEL_LINES);
52
+ }
53
+ i = j + 1; // swallow (wheel or click/motion)
54
+ continue;
55
+ }
56
+ // ESC [ < not followed by digits→M/m: not a mouse seq, pass ESC through
57
+ kept.push(buf.subarray(i, i + 1));
58
+ i++;
59
+ continue;
60
+ }
61
+ // X10 mouse: ESC [ M Cb Cx Cy
62
+ if (buf[i] === 0x1b && i + 2 < n && buf[i + 1] === 0x5b && buf[i + 2] === 0x4d) {
63
+ if (i + 5 >= n) {
64
+ this.pending = buf.subarray(i); // split across chunks
65
+ break;
66
+ }
67
+ const cb = buf[i + 3] - 32;
68
+ if ((cb & 64) !== 0) {
69
+ wheels.push((cb & 1) === 0 ? -WHEEL_LINES : WHEEL_LINES);
70
+ }
71
+ i += 6; // swallow
72
+ continue;
73
+ }
74
+ // Trailing partial that could ONLY be a split SGR/X10 start: hold it.
75
+ // A lone trailing ESC passes through (Esc key must not lag).
76
+ const tail = n - i;
77
+ if (tail <= 5) {
78
+ const rest = buf.subarray(i).toString('latin1');
79
+ if (/^\x1b\[<$/.test(rest) || /^\x1b\[<[\d;]+$/.test(rest) || /^\x1b\[M.{0,2}$/.test(rest)) {
80
+ this.pending = buf.subarray(i);
81
+ break;
82
+ }
83
+ }
84
+ kept.push(buf.subarray(i, i + 1));
85
+ i++;
86
+ }
87
+ return { kept: Buffer.concat(kept), wheels };
88
+ }
89
+ reset() {
90
+ this.pending = Buffer.alloc(0);
91
+ }
92
+ }
93
+ export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h'; // button events + SGR coords
94
+ export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * scroll.md diagnostic: realistic session flow — seed history, stream a long
4
+ * answer in chunks (like provider deltas), scroll mid-stream, stream more.
5
+ * Asserts the chat-flow invariants the user actually sees:
6
+ * - follow-tail: latest streamed text visible while at bottom
7
+ * - freeze: pinned viewport doesn't move while streaming
8
+ * - badge counts new lines
9
+ * - frame never exceeds terminal rows (I1)
10
+ */
11
+ import { describe, it, expect } from 'vitest';
12
+ import { render } from 'ink-testing-library';
13
+ import { App } from './app.js';
14
+ const PROPS = {
15
+ initialModel: 'm',
16
+ maxSteps: 10,
17
+ cwd: '/test',
18
+ onPrompt: async () => { },
19
+ onSlash: async () => { },
20
+ };
21
+ const g = globalThis;
22
+ const tick = (ms = 30) => new Promise((r) => setTimeout(r, ms));
23
+ const rowsOf = (frame) => frame.split('\n').length;
24
+ function seed(n) {
25
+ return Array.from({ length: n }, (_, i) => ({
26
+ id: `seed-${i}`,
27
+ kind: 'text',
28
+ text: `MSG-${i.toString().padStart(2, '0')}-tag`,
29
+ role: 'user',
30
+ }));
31
+ }
32
+ describe('scroll flow diagnostics', () => {
33
+ it('reports terminal geometry (debug aid)', async () => {
34
+ const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true }));
35
+ await tick(50);
36
+ const frame = lastFrame() ?? '';
37
+ // eslint-disable-next-line no-console
38
+ console.log(`[diag] frame rows=${rowsOf(frame)} cols~${(frame.split('\n')[0] ?? '').length}`);
39
+ expect(rowsOf(frame)).toBeLessThanOrEqual(32);
40
+ });
41
+ it('follow-tail: streamed long answer stays visible, frame stays bounded', async () => {
42
+ const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: seed(5) }));
43
+ await tick(50);
44
+ g.__klyroAppStatus({ status: 'running' });
45
+ const chunk = 'STREAMCHUNK lorem ipsum dolor sit amet. ';
46
+ for (let i = 0; i < 12; i++) {
47
+ g.__klyroAppendDelta(`${chunk}#${i} `);
48
+ await tick(40);
49
+ const frame = lastFrame() ?? '';
50
+ expect(rowsOf(frame)).toBeLessThanOrEqual(32);
51
+ // latest streamed chunk must be visible (follow-tail)
52
+ expect(frame).toContain(`#${i}`);
53
+ }
54
+ });
55
+ it('freeze: pinned top survives streaming, badge counts, End restores', async () => {
56
+ const { stdin, lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: seed(40) }));
57
+ await tick(100);
58
+ stdin.write('\x1b[H'); // Home → top
59
+ await tick(50);
60
+ const top = lastFrame() ?? '';
61
+ expect(top).toContain('MSG-00-tag');
62
+ g.__klyroAppStatus({ status: 'running' });
63
+ for (let i = 0; i < 5; i++) {
64
+ g.__klyroAppendDelta(`late chunk number ${i} with filler words here. `);
65
+ await tick(40);
66
+ }
67
+ const frozen = lastFrame() ?? '';
68
+ expect(frozen).toContain('MSG-00-tag'); // viewport did not yank down
69
+ expect(frozen).toMatch(/↓ \d+ new/); // badge visible
70
+ expect(rowsOf(frozen)).toBeLessThanOrEqual(32);
71
+ stdin.write('\x1b[F'); // End → follow
72
+ await tick(50);
73
+ expect(lastFrame() ?? '').toContain('number 4');
74
+ });
75
+ it('wrapped long item: pin mid-item, stream, same first line stays', async () => {
76
+ const long = Array.from({ length: 10 }, (_, i) => `WRAPLINE-${i} ` + 'x'.repeat(180)).join('\n');
77
+ const items = [
78
+ { id: 'w1', kind: 'text', text: long, role: 'assistant' },
79
+ ...seed(30),
80
+ ];
81
+ const { stdin, lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: items }));
82
+ await tick(100);
83
+ stdin.write('\x1b[H');
84
+ await tick(50);
85
+ const before = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
86
+ g.__klyroAppStatus({ status: 'running' });
87
+ for (let i = 0; i < 5; i++) {
88
+ g.__klyroAppendDelta(`more streamed text ${i} ` + 'y'.repeat(120));
89
+ await tick(40);
90
+ }
91
+ const after = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
92
+ expect(after).toBe(before); // anchor stability at line granularity
93
+ });
94
+ });
@@ -22,10 +22,13 @@ export function maxTopFor(ctx) {
22
22
  function stickBottom() {
23
23
  return { anchor: { mode: 'bottom' }, userScrolled: false, newSinceUnstick: 0 };
24
24
  }
25
- function pinAt(s, ctx, row) {
25
+ // FOLLOW_EPSILON is directional: scrolling DOWN into the last line re-sticks
26
+ // to bottom, but scrolling UP must always escape — otherwise single-line /
27
+ // wheel scrolling from the bottom could never leave it (dead scroll trap).
28
+ function pinAt(s, ctx, row, from) {
26
29
  const maxTop = maxTopFor(ctx);
27
30
  const top = clampN(row, 0, maxTop);
28
- if (top >= maxTop - FOLLOW_EPSILON)
31
+ if (top >= maxTop - FOLLOW_EPSILON && top >= from)
29
32
  return stickBottom();
30
33
  if (ctx.count === 0)
31
34
  return stickBottom();
@@ -42,13 +45,13 @@ export function scrollReducer(s, a, ctx) {
42
45
  const cur = resolveTopRow(s, ctx).topRow;
43
46
  switch (a.type) {
44
47
  case 'BY_LINES':
45
- return pinAt(s, ctx, cur + a.delta);
48
+ return pinAt(s, ctx, cur + a.delta, cur);
46
49
  case 'BY_PAGE':
47
- return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1)); // 1-line overlap
50
+ return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1), cur); // 1-line overlap
48
51
  case 'BY_HALF_PAGE':
49
- return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2));
52
+ return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2), cur);
50
53
  case 'TO_TOP':
51
- return pinAt(s, ctx, 0);
54
+ return pinAt(s, ctx, 0, cur);
52
55
  case 'TO_BOTTOM':
53
56
  return stickBottom();
54
57
  case 'CONTENT_GREW':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
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",