klyro 0.1.44 → 0.1.46
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 +1141 -9
- package/dist/cli/slash/parser.d.ts +182 -0
- package/dist/cli/slash/parser.js +209 -1
- package/dist/tui/app.js +314 -130
- package/dist/tui/app.test.js +48 -0
- package/dist/tui/measure.d.ts +85 -0
- package/dist/tui/measure.js +186 -0
- package/dist/tui/scroll-model.d.ts +67 -0
- package/dist/tui/scroll-model.js +96 -0
- package/package.json +1 -1
package/dist/tui/app.test.js
CHANGED
|
@@ -201,6 +201,54 @@ 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
|
+
});
|
|
204
252
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
205
253
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
206
254
|
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,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;
|
|
@@ -0,0 +1,96 @@
|
|
|
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 const FOLLOW_EPSILON = 1; // within 1 line of bottom counts as "at bottom" (§13)
|
|
11
|
+
export const initialScroll = {
|
|
12
|
+
anchor: { mode: 'bottom' },
|
|
13
|
+
userScrolled: false,
|
|
14
|
+
newSinceUnstick: 0,
|
|
15
|
+
};
|
|
16
|
+
function clampN(n, lo, hi) {
|
|
17
|
+
return Math.max(lo, Math.min(hi, n));
|
|
18
|
+
}
|
|
19
|
+
export function maxTopFor(ctx) {
|
|
20
|
+
return Math.max(0, ctx.total - ctx.viewportH);
|
|
21
|
+
}
|
|
22
|
+
function stickBottom() {
|
|
23
|
+
return { anchor: { mode: 'bottom' }, userScrolled: false, newSinceUnstick: 0 };
|
|
24
|
+
}
|
|
25
|
+
function pinAt(s, ctx, row) {
|
|
26
|
+
const maxTop = maxTopFor(ctx);
|
|
27
|
+
const top = clampN(row, 0, maxTop);
|
|
28
|
+
if (top >= maxTop - FOLLOW_EPSILON)
|
|
29
|
+
return stickBottom();
|
|
30
|
+
if (ctx.count === 0)
|
|
31
|
+
return stickBottom();
|
|
32
|
+
const i = clampN(ctx.indexAt(top), 0, ctx.count - 1);
|
|
33
|
+
return {
|
|
34
|
+
anchor: { mode: 'pinned', itemId: ctx.keyOf(i), lineInItem: top - ctx.offsetOf(i) },
|
|
35
|
+
userScrolled: true,
|
|
36
|
+
newSinceUnstick: s.newSinceUnstick,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function scrollReducer(s, a, ctx) {
|
|
40
|
+
const maxTop = maxTopFor(ctx);
|
|
41
|
+
// current resolved top (anchor may predate this frame's measurements)
|
|
42
|
+
const cur = resolveTopRow(s, ctx).topRow;
|
|
43
|
+
switch (a.type) {
|
|
44
|
+
case 'BY_LINES':
|
|
45
|
+
return pinAt(s, ctx, cur + a.delta);
|
|
46
|
+
case 'BY_PAGE':
|
|
47
|
+
return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1)); // 1-line overlap
|
|
48
|
+
case 'BY_HALF_PAGE':
|
|
49
|
+
return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2));
|
|
50
|
+
case 'TO_TOP':
|
|
51
|
+
return pinAt(s, ctx, 0);
|
|
52
|
+
case 'TO_BOTTOM':
|
|
53
|
+
return stickBottom();
|
|
54
|
+
case 'CONTENT_GREW':
|
|
55
|
+
if (a.lines <= 0) {
|
|
56
|
+
// tail shrank/rewrote (§12): keep anchor, recompute, don't count
|
|
57
|
+
return { ...s };
|
|
58
|
+
}
|
|
59
|
+
if (s.anchor.mode === 'bottom') {
|
|
60
|
+
// follow — keep the counter clean; topRow derives from maxTop
|
|
61
|
+
return { ...s, newSinceUnstick: 0 };
|
|
62
|
+
}
|
|
63
|
+
void maxTop;
|
|
64
|
+
return { ...s, newSinceUnstick: s.newSinceUnstick + a.lines };
|
|
65
|
+
case 'REFLOW':
|
|
66
|
+
return { ...s };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
|
|
70
|
+
export function resolveTopRow(s, ctx) {
|
|
71
|
+
const maxTop = maxTopFor(ctx);
|
|
72
|
+
if (s.anchor.mode === 'bottom')
|
|
73
|
+
return { topRow: maxTop, atBottom: true };
|
|
74
|
+
let i = -1;
|
|
75
|
+
for (let k = 0; k < ctx.count; k++) {
|
|
76
|
+
if (ctx.keyOf(k) === s.anchor.itemId) {
|
|
77
|
+
i = k;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (i === -1)
|
|
82
|
+
return { topRow: maxTop, atBottom: true }; // pruned → re-stick (§12)
|
|
83
|
+
const raw = ctx.offsetOf(i) + Math.max(0, s.anchor.lineInItem);
|
|
84
|
+
const topRow = clampN(raw, 0, maxTop);
|
|
85
|
+
return { topRow, atBottom: topRow >= maxTop - FOLLOW_EPSILON };
|
|
86
|
+
}
|
|
87
|
+
/** Badge label per §7.2. Empty string = hidden. */
|
|
88
|
+
export function badgeLabel(atBottom, newSinceUnstick, idle) {
|
|
89
|
+
if (atBottom || newSinceUnstick <= 0)
|
|
90
|
+
return '';
|
|
91
|
+
if (idle)
|
|
92
|
+
return '↓ jump to end';
|
|
93
|
+
if (newSinceUnstick >= 1000)
|
|
94
|
+
return '↓ 999+ new';
|
|
95
|
+
return `↓ ${newSinceUnstick} new`;
|
|
96
|
+
}
|
package/package.json
CHANGED