klyro 0.1.45 → 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 +115 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +312 -121
- package/dist/tui/app.test.js +67 -0
- package/dist/tui/measure.d.ts +85 -0
- package/dist/tui/measure.js +186 -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.d.ts +67 -0
- package/dist/tui/scroll-model.js +99 -0
- package/package.json +1 -1
package/dist/tui/app.test.js
CHANGED
|
@@ -201,6 +201,73 @@ describe('App', () => {
|
|
|
201
201
|
const frame = lastFrame() ?? '';
|
|
202
202
|
expect(frame).toMatch(/LATE-1-tag/);
|
|
203
203
|
});
|
|
204
|
+
it('↑ recalls the previous prompt (input history, §8.3)', async () => {
|
|
205
|
+
const onPrompt = vi.fn(async () => { });
|
|
206
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
|
|
207
|
+
stdin.write('first recallable prompt');
|
|
208
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
209
|
+
stdin.write('\x0d');
|
|
210
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
211
|
+
expect(onPrompt).toHaveBeenCalledWith('first recallable prompt');
|
|
212
|
+
// Input cleared after submit; ↑ should restore it from history.
|
|
213
|
+
stdin.write('\x1b[A');
|
|
214
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
215
|
+
expect(lastFrame() ?? '').toContain('first recallable prompt');
|
|
216
|
+
});
|
|
217
|
+
it('↑ on empty input scrolls one line instead of history', async () => {
|
|
218
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
219
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
220
|
+
stdin.write(KEY_HOME);
|
|
221
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
222
|
+
// Empty input + plain ↑ → line up (stays near top, MSG-00 visible).
|
|
223
|
+
stdin.write('\x1b[A');
|
|
224
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
225
|
+
expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
|
|
226
|
+
});
|
|
227
|
+
it('/c shows top-6 suggestions and Tab completes', async () => {
|
|
228
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
229
|
+
stdin.write('/c');
|
|
230
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
231
|
+
const withSuggest = lastFrame() ?? '';
|
|
232
|
+
expect(withSuggest).toMatch(/\/clear/);
|
|
233
|
+
expect(withSuggest).toMatch(/tab to complete/i);
|
|
234
|
+
stdin.write('\t');
|
|
235
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
236
|
+
expect(lastFrame() ?? '').toContain('/clear ');
|
|
237
|
+
});
|
|
238
|
+
it('Enter on empty input while pinned jumps back to bottom (§7.2)', async () => {
|
|
239
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
240
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
241
|
+
stdin.write(KEY_HOME);
|
|
242
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
243
|
+
const g = globalThis;
|
|
244
|
+
g.__klyroAppAppend({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
|
|
245
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
246
|
+
expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
|
|
247
|
+
// Empty input + Enter → dismiss badge, follow tail.
|
|
248
|
+
stdin.write('\x0d');
|
|
249
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
250
|
+
expect(lastFrame() ?? '').toMatch(/LATE-1-tag/);
|
|
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
|
+
});
|
|
204
271
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
205
272
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
206
273
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §4 — Measurement layer (item → display lines).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: no hooks, no side effects. The App measures each
|
|
5
|
+
* transcript block into wrapped display-line heights, caches by
|
|
6
|
+
* (key, width, content-signature), and builds a cumulative line index so
|
|
7
|
+
* scrolling is O(1) per frame and only the streaming tail is re-measured.
|
|
8
|
+
*
|
|
9
|
+
* Heights mirror the render structure in app.tsx:
|
|
10
|
+
* user text = wrapped lines + 1 (marginBottom)
|
|
11
|
+
* assistant text = 1 (guide+Klyro header) + wrapped + 1
|
|
12
|
+
* group collapsed= 1 + 1 margin; expanded = 1 + min(n,12) + overflow?1 + 1
|
|
13
|
+
* error = wrapped + 1 margin
|
|
14
|
+
* policy = 0 (renders null)
|
|
15
|
+
* file_changed = 1 + 1 margin
|
|
16
|
+
* diff = 1 summary + Σ(1 path + wrapped hunk lines) + 1 margin
|
|
17
|
+
* plan block = 1 + min(8, steps) + 1 margin
|
|
18
|
+
* thinking/queue = content lines + 1 margin
|
|
19
|
+
*/
|
|
20
|
+
/** Display width per scroll.md §12: never `.length` (CJK=2, emoji=2, combining=0). */
|
|
21
|
+
export declare function displayWidth(s: string): number;
|
|
22
|
+
/** Wrapped display-line count for already-newline-split text at `width`. */
|
|
23
|
+
export declare function wrapCount(text: string, width: number): number;
|
|
24
|
+
/** Content width inside the transcript column (guide prefix + padding). */
|
|
25
|
+
export declare function contentWidth(termWidth: number): number;
|
|
26
|
+
export type BlockDesc = {
|
|
27
|
+
kind: 'user';
|
|
28
|
+
text: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'assistant';
|
|
31
|
+
text: string;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'group';
|
|
34
|
+
count: number;
|
|
35
|
+
expanded: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'error';
|
|
38
|
+
message: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: 'policy';
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'file';
|
|
43
|
+
path: string;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'diff';
|
|
46
|
+
hunks: Array<{
|
|
47
|
+
path: string;
|
|
48
|
+
lines: string[];
|
|
49
|
+
}>;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'plan';
|
|
52
|
+
done: number;
|
|
53
|
+
total: number;
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'thinking';
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'queued';
|
|
58
|
+
count: number;
|
|
59
|
+
};
|
|
60
|
+
export declare const EXPANDED_DETAIL_CAP = 12;
|
|
61
|
+
export declare function blockHeight(b: BlockDesc, termWidth: number): number;
|
|
62
|
+
/** Content fingerprint: changes whenever the block's rendered lines could change. */
|
|
63
|
+
export declare function blockSig(b: BlockDesc): string;
|
|
64
|
+
/** Cache: key → height. Only the streaming tail changes sig per tick (I6 → O(1)). */
|
|
65
|
+
export declare class MeasureCache {
|
|
66
|
+
private map;
|
|
67
|
+
private maxEntries;
|
|
68
|
+
constructor(maxEntries?: number);
|
|
69
|
+
heightFor(key: string, block: BlockDesc, termWidth: number): number;
|
|
70
|
+
/** Resize (width change) invalidates everything — scroll.md §12. */
|
|
71
|
+
invalidateWidth(): void;
|
|
72
|
+
get size(): number;
|
|
73
|
+
}
|
|
74
|
+
export interface LineIndex {
|
|
75
|
+
offsets: number[];
|
|
76
|
+
total: number;
|
|
77
|
+
}
|
|
78
|
+
/** Cumulative first-row offsets; dirtyFrom allows incremental rebuild (only tail dirtied). */
|
|
79
|
+
export declare function buildIndex(heights: number[], prev?: {
|
|
80
|
+
offsets: number[];
|
|
81
|
+
dirtyFrom: number;
|
|
82
|
+
}): LineIndex;
|
|
83
|
+
/** Binary search: display row → block index (upperBound(offsets, row) - 1). */
|
|
84
|
+
export declare function itemAtRow(offsets: number[], row: number): number;
|
|
85
|
+
export declare function clamp(n: number, lo: number, hi: number): number;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §4 — Measurement layer (item → display lines).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: no hooks, no side effects. The App measures each
|
|
5
|
+
* transcript block into wrapped display-line heights, caches by
|
|
6
|
+
* (key, width, content-signature), and builds a cumulative line index so
|
|
7
|
+
* scrolling is O(1) per frame and only the streaming tail is re-measured.
|
|
8
|
+
*
|
|
9
|
+
* Heights mirror the render structure in app.tsx:
|
|
10
|
+
* user text = wrapped lines + 1 (marginBottom)
|
|
11
|
+
* assistant text = 1 (guide+Klyro header) + wrapped + 1
|
|
12
|
+
* group collapsed= 1 + 1 margin; expanded = 1 + min(n,12) + overflow?1 + 1
|
|
13
|
+
* error = wrapped + 1 margin
|
|
14
|
+
* policy = 0 (renders null)
|
|
15
|
+
* file_changed = 1 + 1 margin
|
|
16
|
+
* diff = 1 summary + Σ(1 path + wrapped hunk lines) + 1 margin
|
|
17
|
+
* plan block = 1 + min(8, steps) + 1 margin
|
|
18
|
+
* thinking/queue = content lines + 1 margin
|
|
19
|
+
*/
|
|
20
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
21
|
+
function isWide(cp) {
|
|
22
|
+
return ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
23
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
24
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
25
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
26
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
27
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
28
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
29
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
30
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
31
|
+
(cp >= 0x2b00 && cp <= 0x2bff));
|
|
32
|
+
}
|
|
33
|
+
function isZeroWidth(cp) {
|
|
34
|
+
return ((cp >= 0x0300 && cp <= 0x036f) ||
|
|
35
|
+
(cp >= 0x200b && cp <= 0x200f) ||
|
|
36
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
37
|
+
cp === 0x00ad);
|
|
38
|
+
}
|
|
39
|
+
/** Display width per scroll.md §12: never `.length` (CJK=2, emoji=2, combining=0). */
|
|
40
|
+
export function displayWidth(s) {
|
|
41
|
+
const clean = s.replace(ANSI_RE, '').replace(/\t/g, ' ');
|
|
42
|
+
let w = 0;
|
|
43
|
+
for (const ch of clean) {
|
|
44
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
45
|
+
if (isZeroWidth(cp))
|
|
46
|
+
continue;
|
|
47
|
+
w += isWide(cp) ? 2 : 1;
|
|
48
|
+
}
|
|
49
|
+
return w;
|
|
50
|
+
}
|
|
51
|
+
/** Wrapped display-line count for already-newline-split text at `width`. */
|
|
52
|
+
export function wrapCount(text, width) {
|
|
53
|
+
const w = Math.max(1, width);
|
|
54
|
+
let n = 0;
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
const dw = displayWidth(line);
|
|
57
|
+
n += dw === 0 ? 1 : Math.max(1, Math.ceil(dw / w));
|
|
58
|
+
}
|
|
59
|
+
return Math.max(1, n);
|
|
60
|
+
}
|
|
61
|
+
/** Content width inside the transcript column (guide prefix + padding). */
|
|
62
|
+
export function contentWidth(termWidth) {
|
|
63
|
+
return Math.max(20, termWidth - 10);
|
|
64
|
+
}
|
|
65
|
+
export const EXPANDED_DETAIL_CAP = 12; // scroll.md §9.3
|
|
66
|
+
export function blockHeight(b, termWidth) {
|
|
67
|
+
const cw = contentWidth(termWidth);
|
|
68
|
+
switch (b.kind) {
|
|
69
|
+
case 'user':
|
|
70
|
+
return wrapCount(b.text, termWidth) + 1;
|
|
71
|
+
case 'assistant':
|
|
72
|
+
return 1 + wrapCount(b.text, cw) + 1;
|
|
73
|
+
case 'group':
|
|
74
|
+
if (!b.expanded)
|
|
75
|
+
return 1 + 1;
|
|
76
|
+
return 1 + Math.min(b.count, EXPANDED_DETAIL_CAP) + (b.count > EXPANDED_DETAIL_CAP ? 1 : 0) + 1;
|
|
77
|
+
case 'error':
|
|
78
|
+
return wrapCount(b.message, termWidth) + 1;
|
|
79
|
+
case 'policy':
|
|
80
|
+
return 0;
|
|
81
|
+
case 'file':
|
|
82
|
+
return 1 + 1;
|
|
83
|
+
case 'diff': {
|
|
84
|
+
let n = 1; // summary
|
|
85
|
+
for (const h of b.hunks)
|
|
86
|
+
n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw), 0);
|
|
87
|
+
return n + 1;
|
|
88
|
+
}
|
|
89
|
+
case 'plan':
|
|
90
|
+
return 1 + Math.min(8, b.total) + 1;
|
|
91
|
+
case 'thinking':
|
|
92
|
+
return 1 + 1;
|
|
93
|
+
case 'queued':
|
|
94
|
+
return b.count + 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Content fingerprint: changes whenever the block's rendered lines could change. */
|
|
98
|
+
export function blockSig(b) {
|
|
99
|
+
switch (b.kind) {
|
|
100
|
+
case 'user':
|
|
101
|
+
return `u:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
|
|
102
|
+
case 'assistant':
|
|
103
|
+
return `a:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
|
|
104
|
+
case 'group':
|
|
105
|
+
return `g:${b.count}:${b.expanded ? 1 : 0}`;
|
|
106
|
+
case 'error':
|
|
107
|
+
return `e:${b.message.length}:${b.message.slice(-32)}`;
|
|
108
|
+
case 'policy':
|
|
109
|
+
return 'p';
|
|
110
|
+
case 'file':
|
|
111
|
+
return `f:${b.path}`;
|
|
112
|
+
case 'diff':
|
|
113
|
+
return `d:${b.hunks.length}:${b.hunks.reduce((s, h) => s + h.lines.length, 0)}`;
|
|
114
|
+
case 'plan':
|
|
115
|
+
return `pl:${b.done}/${b.total}`;
|
|
116
|
+
case 'thinking':
|
|
117
|
+
return 't';
|
|
118
|
+
case 'queued':
|
|
119
|
+
return `q:${b.count}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Cache: key → height. Only the streaming tail changes sig per tick (I6 → O(1)). */
|
|
123
|
+
export class MeasureCache {
|
|
124
|
+
map = new Map();
|
|
125
|
+
maxEntries;
|
|
126
|
+
constructor(maxEntries = 2000) {
|
|
127
|
+
this.maxEntries = maxEntries;
|
|
128
|
+
}
|
|
129
|
+
heightFor(key, block, termWidth) {
|
|
130
|
+
const sig = blockSig(block);
|
|
131
|
+
const hit = this.map.get(key);
|
|
132
|
+
if (hit && hit.width === termWidth && hit.sig === sig)
|
|
133
|
+
return hit.height;
|
|
134
|
+
const height = blockHeight(block, termWidth);
|
|
135
|
+
if (this.map.size >= this.maxEntries) {
|
|
136
|
+
const oldest = this.map.keys().next();
|
|
137
|
+
if (!oldest.done)
|
|
138
|
+
this.map.delete(oldest.value);
|
|
139
|
+
}
|
|
140
|
+
this.map.set(key, { width: termWidth, sig, height });
|
|
141
|
+
return height;
|
|
142
|
+
}
|
|
143
|
+
/** Resize (width change) invalidates everything — scroll.md §12. */
|
|
144
|
+
invalidateWidth() {
|
|
145
|
+
this.map.clear();
|
|
146
|
+
}
|
|
147
|
+
get size() {
|
|
148
|
+
return this.map.size;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Cumulative first-row offsets; dirtyFrom allows incremental rebuild (only tail dirtied). */
|
|
152
|
+
export function buildIndex(heights, prev) {
|
|
153
|
+
const offsets = prev ? [...prev.offsets] : [];
|
|
154
|
+
const from = prev ? Math.min(prev.dirtyFrom, heights.length) : 0;
|
|
155
|
+
for (let i = Math.max(0, from); i < heights.length; i++) {
|
|
156
|
+
offsets[i] = i === 0 ? 0 : offsets[i - 1] + heights[i - 1];
|
|
157
|
+
}
|
|
158
|
+
offsets.length = heights.length;
|
|
159
|
+
const last = heights.length - 1;
|
|
160
|
+
const total = last < 0 ? 0 : offsets[last] + heights[last];
|
|
161
|
+
return { offsets, total };
|
|
162
|
+
}
|
|
163
|
+
/** Binary search: display row → block index (upperBound(offsets, row) - 1). */
|
|
164
|
+
export function itemAtRow(offsets, row) {
|
|
165
|
+
if (offsets.length === 0)
|
|
166
|
+
return -1;
|
|
167
|
+
if (row <= 0)
|
|
168
|
+
return 0;
|
|
169
|
+
let lo = 0;
|
|
170
|
+
let hi = offsets.length - 1;
|
|
171
|
+
let ans = 0;
|
|
172
|
+
while (lo <= hi) {
|
|
173
|
+
const mid = (lo + hi) >> 1;
|
|
174
|
+
if (offsets[mid] <= row) {
|
|
175
|
+
ans = mid;
|
|
176
|
+
lo = mid + 1;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
hi = mid - 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return ans;
|
|
183
|
+
}
|
|
184
|
+
export function clamp(n, lo, hi) {
|
|
185
|
+
return Math.max(lo, Math.min(hi, n));
|
|
186
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §5 — Anchor-based scroll model (pure, no React).
|
|
3
|
+
*
|
|
4
|
+
* Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
|
|
5
|
+
* so resizes, group expand/collapse, and compaction never make the view jump.
|
|
6
|
+
* While anchored to 'bottom', new output follows; the instant the user scrolls
|
|
7
|
+
* up the anchor pins to an item and freezes, accumulating newSinceUnstick
|
|
8
|
+
* display lines for the `↓ N new` badge (§7).
|
|
9
|
+
*/
|
|
10
|
+
export type Anchor = {
|
|
11
|
+
mode: 'bottom';
|
|
12
|
+
} | {
|
|
13
|
+
mode: 'pinned';
|
|
14
|
+
itemId: string;
|
|
15
|
+
lineInItem: number;
|
|
16
|
+
};
|
|
17
|
+
export interface ScrollState {
|
|
18
|
+
anchor: Anchor;
|
|
19
|
+
userScrolled: boolean;
|
|
20
|
+
/** display lines appended since the user unstuck (badge count, §7) */
|
|
21
|
+
newSinceUnstick: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const FOLLOW_EPSILON = 1;
|
|
24
|
+
export declare const initialScroll: ScrollState;
|
|
25
|
+
export type ScrollAction = {
|
|
26
|
+
type: 'BY_LINES';
|
|
27
|
+
delta: number;
|
|
28
|
+
} | {
|
|
29
|
+
type: 'BY_PAGE';
|
|
30
|
+
dir: -1 | 1;
|
|
31
|
+
} | {
|
|
32
|
+
type: 'BY_HALF_PAGE';
|
|
33
|
+
dir: -1 | 1;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'TO_TOP';
|
|
36
|
+
} | {
|
|
37
|
+
type: 'TO_BOTTOM';
|
|
38
|
+
} | {
|
|
39
|
+
type: 'CONTENT_GREW';
|
|
40
|
+
lines: number;
|
|
41
|
+
} | {
|
|
42
|
+
type: 'REFLOW';
|
|
43
|
+
};
|
|
44
|
+
/** Index abstraction over measured display lines (§4.3). */
|
|
45
|
+
export interface ScrollCtx {
|
|
46
|
+
/** number of blocks */
|
|
47
|
+
count: number;
|
|
48
|
+
/** display-line offset of block i */
|
|
49
|
+
offsetOf: (i: number) => number;
|
|
50
|
+
/** key of block i (stable across regroups) */
|
|
51
|
+
keyOf: (i: number) => string;
|
|
52
|
+
/** block index containing display row */
|
|
53
|
+
indexAt: (row: number) => number;
|
|
54
|
+
/** total measured display lines */
|
|
55
|
+
total: number;
|
|
56
|
+
viewportH: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function maxTopFor(ctx: ScrollCtx): number;
|
|
59
|
+
export declare function scrollReducer(s: ScrollState, a: ScrollAction, ctx: ScrollCtx): ScrollState;
|
|
60
|
+
export interface Resolved {
|
|
61
|
+
topRow: number;
|
|
62
|
+
atBottom: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
|
|
65
|
+
export declare function resolveTopRow(s: ScrollState, ctx: ScrollCtx): Resolved;
|
|
66
|
+
/** Badge label per §7.2. Empty string = hidden. */
|
|
67
|
+
export declare function badgeLabel(atBottom: boolean, newSinceUnstick: number, idle: boolean): string;
|