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 +39 -0
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +22 -3
- package/dist/tui/app.test.js +19 -0
- package/dist/tui/mouse.d.ts +36 -0
- package/dist/tui/mouse.js +94 -0
- package/dist/tui/scroll-flow.test.d.ts +1 -0
- package/dist/tui/scroll-flow.test.js +94 -0
- package/dist/tui/scroll-model.js +9 -6
- package/package.json +1 -1
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
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/tui/scroll-model.js
CHANGED
|
@@ -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
|
-
|
|
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