klyro 0.1.45 → 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 +76 -1
- package/dist/tui/app.js +290 -118
- 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/cli/repl.js
CHANGED
|
@@ -85,7 +85,25 @@ export async function startRepl(opts = {}) {
|
|
|
85
85
|
const pendingQueue = [];
|
|
86
86
|
let isMounted = false;
|
|
87
87
|
let directHooks;
|
|
88
|
+
// Plain-text mirror for exit replay (scroll.md §1.2: session survives in
|
|
89
|
+
// native scrollback after the alt screen is torn down). Cap 300 lines.
|
|
90
|
+
const exitMirror = [];
|
|
91
|
+
function mirrorLine(item) {
|
|
92
|
+
let line = null;
|
|
93
|
+
if (item.kind === 'text')
|
|
94
|
+
line = `${item.role === 'user' ? '> ' : ''}${item.text}`;
|
|
95
|
+
else if (item.kind === 'error')
|
|
96
|
+
line = `[error] ${item.message}`;
|
|
97
|
+
else if (item.kind === 'file_changed')
|
|
98
|
+
line = `[${item.op}] ${item.path}`;
|
|
99
|
+
if (line === null)
|
|
100
|
+
return;
|
|
101
|
+
exitMirror.push(line.slice(0, 2000));
|
|
102
|
+
if (exitMirror.length > 300)
|
|
103
|
+
exitMirror.splice(0, exitMirror.length - 300);
|
|
104
|
+
}
|
|
88
105
|
function queuedAppend(item) {
|
|
106
|
+
mirrorLine(item);
|
|
89
107
|
if (isMounted && directHooks)
|
|
90
108
|
directHooks.append(item);
|
|
91
109
|
else
|
|
@@ -140,6 +158,49 @@ export async function startRepl(opts = {}) {
|
|
|
140
158
|
let tuiSessionId;
|
|
141
159
|
if (isAltScreen)
|
|
142
160
|
enterAlt();
|
|
161
|
+
// I7 (scroll.md §8.6, S8): while the TUI owns stdout, route console.*
|
|
162
|
+
// to a ring buffer + ~/.klyro/debug.log so stray tool/provider logs
|
|
163
|
+
// can't corrupt the frame. Restored on exit.
|
|
164
|
+
const consoleRing = [];
|
|
165
|
+
const origConsoleFns = {
|
|
166
|
+
log: console.log,
|
|
167
|
+
info: console.info,
|
|
168
|
+
warn: console.warn,
|
|
169
|
+
error: console.error,
|
|
170
|
+
debug: console.debug,
|
|
171
|
+
};
|
|
172
|
+
function patchConsole() {
|
|
173
|
+
if (!isAltScreen)
|
|
174
|
+
return;
|
|
175
|
+
const sink = (...args) => {
|
|
176
|
+
const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
|
|
177
|
+
consoleRing.push(line);
|
|
178
|
+
if (consoleRing.length > 200)
|
|
179
|
+
consoleRing.splice(0, consoleRing.length - 200);
|
|
180
|
+
try {
|
|
181
|
+
const fs = require('node:fs');
|
|
182
|
+
const path = require('node:path');
|
|
183
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
|
|
184
|
+
const dir = path.join(home, '.klyro');
|
|
185
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
186
|
+
fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
|
|
187
|
+
}
|
|
188
|
+
catch { /* ignore */ }
|
|
189
|
+
};
|
|
190
|
+
console.log = sink;
|
|
191
|
+
console.info = sink;
|
|
192
|
+
console.warn = sink;
|
|
193
|
+
console.error = sink;
|
|
194
|
+
console.debug = sink;
|
|
195
|
+
}
|
|
196
|
+
function restoreConsole() {
|
|
197
|
+
console.log = origConsoleFns.log;
|
|
198
|
+
console.info = origConsoleFns.info;
|
|
199
|
+
console.warn = origConsoleFns.warn;
|
|
200
|
+
console.error = origConsoleFns.error;
|
|
201
|
+
console.debug = origConsoleFns.debug;
|
|
202
|
+
}
|
|
203
|
+
patchConsole();
|
|
143
204
|
const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
|
|
144
205
|
// P1 session/permission state (commands.md Priority 1)
|
|
145
206
|
let sessionLabel = '';
|
|
@@ -278,6 +339,7 @@ export async function startRepl(opts = {}) {
|
|
|
278
339
|
leaveAlt();
|
|
279
340
|
};
|
|
280
341
|
process.once('SIGINT', sigintHandler);
|
|
342
|
+
process.once('SIGTERM', sigintHandler);
|
|
281
343
|
async function runWithBridge(text) {
|
|
282
344
|
if (!model) {
|
|
283
345
|
queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
|
|
@@ -2092,9 +2154,22 @@ export async function startRepl(opts = {}) {
|
|
|
2092
2154
|
// ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
|
|
2093
2155
|
return new Promise((resolve) => {
|
|
2094
2156
|
const onExit = () => {
|
|
2095
|
-
if (sigintHandler)
|
|
2157
|
+
if (sigintHandler) {
|
|
2096
2158
|
process.removeListener('SIGINT', sigintHandler);
|
|
2159
|
+
process.removeListener('SIGTERM', sigintHandler);
|
|
2160
|
+
}
|
|
2161
|
+
restoreConsole();
|
|
2097
2162
|
leaveAlt();
|
|
2163
|
+
// §1.2 exit behavior: replay a plain-text transcript into the main
|
|
2164
|
+
// buffer so the session survives in native scrollback.
|
|
2165
|
+
if (exitMirror.length > 0) {
|
|
2166
|
+
try {
|
|
2167
|
+
process.stdout.write('\n--- klyro session transcript ---\n');
|
|
2168
|
+
for (const line of exitMirror.slice(-100))
|
|
2169
|
+
process.stdout.write(line + '\n');
|
|
2170
|
+
}
|
|
2171
|
+
catch { /* ignore */ }
|
|
2172
|
+
}
|
|
2098
2173
|
resolve(ac.signal.aborted ? 130 : 0);
|
|
2099
2174
|
};
|
|
2100
2175
|
if (!app) {
|
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Klyro TUI - opencode-clean - no clumsy words, correct wrap, markdown, scroll
|
|
4
4
|
* Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
|
|
@@ -9,6 +9,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
10
10
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
11
11
|
import { tokens, g } from './tokens.js';
|
|
12
|
+
import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
|
|
13
|
+
import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
|
|
12
14
|
let _id = 0;
|
|
13
15
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
14
16
|
function Header({ cwd, model, version, width }) {
|
|
@@ -42,29 +44,34 @@ function verbForTool(name) {
|
|
|
42
44
|
return 'Edited';
|
|
43
45
|
return 'Called';
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Aggregator (scroll.md §9): merge CONSECUTIVE same-op tool events into one
|
|
49
|
+
* ActivityGroup. Never merge across a different op — read,read,edit,read →
|
|
50
|
+
* 3 groups, not 2. Group ids are content-derived (stable across regroups so
|
|
51
|
+
* scroll anchors and expansion state survive streaming appends).
|
|
52
|
+
*/
|
|
45
53
|
function groupTools(items) {
|
|
46
54
|
const out = [];
|
|
47
55
|
let cur = [];
|
|
56
|
+
let curVerb = null;
|
|
48
57
|
const flush = () => {
|
|
49
58
|
if (cur.length === 0)
|
|
50
59
|
return;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
byVerb.set(v, []);
|
|
56
|
-
byVerb.get(v).push(it);
|
|
57
|
-
}
|
|
58
|
-
for (const [verb, list] of byVerb) {
|
|
59
|
-
const totalMs = list.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
60
|
-
const status = list.some((x) => x.isError || x.status === 'error') ? 'error' : list.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
61
|
-
out.push({ id: nextId('g'), verb, items: list, totalMs, status });
|
|
62
|
-
}
|
|
60
|
+
const verb = curVerb;
|
|
61
|
+
const totalMs = cur.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
62
|
+
const status = cur.some((x) => x.isError || x.status === 'error') ? 'error' : cur.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
63
|
+
out.push({ id: `g:${verb}:${cur.map((i) => i.id_call).join('|')}`, verb, items: cur, totalMs, status });
|
|
63
64
|
cur = [];
|
|
65
|
+
curVerb = null;
|
|
64
66
|
};
|
|
65
67
|
for (const it of items) {
|
|
66
|
-
if (it.kind === 'tool')
|
|
68
|
+
if (it.kind === 'tool') {
|
|
69
|
+
const v = verbForTool(it.name);
|
|
70
|
+
if (curVerb !== null && v !== curVerb)
|
|
71
|
+
flush(); // different op → break the group
|
|
72
|
+
curVerb = v;
|
|
67
73
|
cur.push(it);
|
|
74
|
+
}
|
|
68
75
|
else {
|
|
69
76
|
flush();
|
|
70
77
|
out.push(it);
|
|
@@ -94,78 +101,86 @@ function MarkdownText({ text, dim, width }) {
|
|
|
94
101
|
// Render as single line with bold segments — Ink will wrap the parent Box
|
|
95
102
|
return _jsx(Text, { wrap: "wrap", children: parts });
|
|
96
103
|
}
|
|
97
|
-
// Chat scroll
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
104
|
+
// Chat scroll — scroll.md §5 anchor model adapted to Ink.
|
|
105
|
+
//
|
|
106
|
+
// Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
|
|
107
|
+
// resolved per frame against measured *display lines* (I3, see measure.ts).
|
|
108
|
+
// While anchored to 'bottom', new output follows; scrolling up pins to an
|
|
109
|
+
// item and freezes, accumulating newSinceUnstick lines for the `↓ N new` pill.
|
|
110
|
+
//
|
|
111
|
+
// Deviation from §7.1: Ink cannot overlay rows, so the badge renders as a
|
|
112
|
+
// one-line pill above the input instead of overwriting the last viewport row.
|
|
102
113
|
function useChatScroll(opts) {
|
|
103
|
-
const {
|
|
104
|
-
const [
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const firstEffectRef = useRef(true);
|
|
114
|
+
const { keys, heights, viewportH, width } = opts;
|
|
115
|
+
const [state, setState] = useState(initialScroll);
|
|
116
|
+
const index = useMemo(() => buildIndex(heights), [heights]);
|
|
117
|
+
const boundaries = useMemo(() => index.offsets.filter((_, i) => heights[i] > 0), [index, heights]);
|
|
118
|
+
const ctx = {
|
|
119
|
+
count: keys.length,
|
|
120
|
+
offsetOf: (i) => index.offsets[i] ?? 0,
|
|
121
|
+
keyOf: (i) => keys[i] ?? '',
|
|
122
|
+
indexAt: (row) => itemAtRow(index.offsets, row),
|
|
123
|
+
total: index.total,
|
|
124
|
+
viewportH,
|
|
125
|
+
};
|
|
126
|
+
const ctxRef = useRef(ctx);
|
|
127
|
+
ctxRef.current = ctx;
|
|
128
|
+
const boundariesRef = useRef(boundaries);
|
|
129
|
+
boundariesRef.current = boundaries;
|
|
130
|
+
// Auto-follow wiring (§6): measured total deltas → CONTENT_GREW;
|
|
131
|
+
// width change → REFLOW (§12). Initial anchor is 'bottom' → follow-tail.
|
|
132
|
+
const prevTotalRef = useRef(index.total);
|
|
133
|
+
const prevWidthRef = useRef(width);
|
|
124
134
|
useEffect(() => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (maxOffset > 0) {
|
|
132
|
-
setScrollOffset(maxOffset);
|
|
133
|
-
}
|
|
135
|
+
const prevTotal = prevTotalRef.current;
|
|
136
|
+
const prevWidth = prevWidthRef.current;
|
|
137
|
+
prevTotalRef.current = index.total;
|
|
138
|
+
prevWidthRef.current = width;
|
|
139
|
+
if (width !== prevWidth) {
|
|
140
|
+
setState((s) => scrollReducer(s, { type: 'REFLOW' }, ctxRef.current));
|
|
134
141
|
return;
|
|
135
142
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const grew = maxOffset - lastMaxOffsetRef.current;
|
|
140
|
-
lastMaxOffsetRef.current = maxOffset;
|
|
141
|
-
if (pinnedRef.current) {
|
|
142
|
-
if (grew > 0)
|
|
143
|
-
setPendingNew((p) => p + grew);
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
// FollowTail: snap to the new bottom.
|
|
147
|
-
setScrollOffset(maxOffset);
|
|
143
|
+
const delta = index.total - prevTotal;
|
|
144
|
+
if (delta !== 0) {
|
|
145
|
+
setState((s) => scrollReducer(s, { type: 'CONTENT_GREW', lines: delta }, ctxRef.current));
|
|
148
146
|
}
|
|
149
|
-
}, [
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
}, [index.total, width]);
|
|
148
|
+
const dispatch = useCallback((a) => {
|
|
149
|
+
setState((s) => scrollReducer(s, a, ctxRef.current));
|
|
150
|
+
}, []);
|
|
151
|
+
const stateRef = useRef(state);
|
|
152
|
+
stateRef.current = state;
|
|
153
|
+
const commands = useMemo(() => ({
|
|
154
|
+
lineUp: () => dispatch({ type: 'BY_LINES', delta: -1 }),
|
|
155
|
+
lineDown: () => dispatch({ type: 'BY_LINES', delta: 1 }),
|
|
153
156
|
pageUp: () => {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
const c = ctxRef.current;
|
|
158
|
+
const top = resolveTopRow(stateRef.current, c).topRow;
|
|
159
|
+
const prev = [...boundariesRef.current].reverse().find((b) => b < top);
|
|
160
|
+
const next = prev ?? Math.max(0, top - (c.viewportH - 1)); // 1-line overlap
|
|
161
|
+
dispatch({ type: 'BY_LINES', delta: next - top });
|
|
158
162
|
},
|
|
159
163
|
pageDown: () => {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
const c = ctxRef.current;
|
|
165
|
+
const top = resolveTopRow(stateRef.current, c).topRow;
|
|
166
|
+
const nxt = boundariesRef.current.find((b) => b > top);
|
|
167
|
+
const next = nxt ?? Math.min(maxTopFor(c), top + (c.viewportH - 1));
|
|
168
|
+
dispatch({ type: 'BY_LINES', delta: next - top });
|
|
164
169
|
},
|
|
165
|
-
jumpTop: () => {
|
|
166
|
-
jumpBottom: () => {
|
|
170
|
+
jumpTop: () => dispatch({ type: 'TO_TOP' }),
|
|
171
|
+
jumpBottom: () => dispatch({ type: 'TO_BOTTOM' }),
|
|
172
|
+
}), [dispatch]);
|
|
173
|
+
const resolved = resolveTopRow(state, ctx);
|
|
174
|
+
const maxTop = maxTopFor(ctx);
|
|
175
|
+
const pinned = state.anchor.mode === 'pinned' && !resolved.atBottom;
|
|
176
|
+
return {
|
|
177
|
+
topRow: resolved.topRow,
|
|
178
|
+
atBottom: resolved.atBottom,
|
|
179
|
+
maxTop,
|
|
180
|
+
pinned,
|
|
181
|
+
pendingNew: state.newSinceUnstick,
|
|
182
|
+
commands,
|
|
167
183
|
};
|
|
168
|
-
return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
|
|
169
184
|
}
|
|
170
185
|
export function App(props) {
|
|
171
186
|
const { stdout } = useStdout();
|
|
@@ -179,27 +194,130 @@ export function App(props) {
|
|
|
179
194
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
180
195
|
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
181
196
|
const streamingIdRef = useRef(null);
|
|
197
|
+
// Input history for contextual ↑/↓ (scroll.md §8.3, S6)
|
|
198
|
+
const [history, setHistory] = useState([]);
|
|
199
|
+
const [histIdx, setHistIdx] = useState(null);
|
|
200
|
+
const pushHistory = useCallback((v) => {
|
|
201
|
+
setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
|
|
202
|
+
setHistIdx(null);
|
|
203
|
+
}, []);
|
|
182
204
|
const width = stdout?.columns ?? 100;
|
|
183
205
|
const height = stdout?.rows ?? 30;
|
|
184
206
|
const isFullscreen = props.isFullscreen ?? false;
|
|
185
207
|
const grouped = groupTools(transcript);
|
|
186
208
|
const viewportH = Math.max(5, height - 10);
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
// Degraded mode (§12): terminal < 10 rows → transcript hidden, input+status only.
|
|
210
|
+
const tiny = height < 10;
|
|
211
|
+
// --- Measured blocks (§4): one entry per grouped item + tail blocks ------
|
|
212
|
+
// Heights are estimated wrapped display lines; the cache makes streaming
|
|
213
|
+
// O(1) (only the tail sig changes per tick, I6).
|
|
214
|
+
const measureCacheRef = useRef(null);
|
|
215
|
+
if (!measureCacheRef.current)
|
|
216
|
+
measureCacheRef.current = new MeasureCache();
|
|
217
|
+
const cache = measureCacheRef.current;
|
|
218
|
+
const blocks = useMemo(() => {
|
|
219
|
+
const out = [];
|
|
220
|
+
grouped.forEach((entry, gi) => {
|
|
221
|
+
if (entry.verb) {
|
|
222
|
+
const gr = entry;
|
|
223
|
+
out.push({
|
|
224
|
+
key: gr.id,
|
|
225
|
+
desc: { kind: 'group', count: gr.items.length, expanded: expandedGroups.has(gr.id) },
|
|
226
|
+
groupIndex: gi,
|
|
227
|
+
tail: null,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const it = entry;
|
|
232
|
+
if (it.kind === 'text' && it.role === 'user') {
|
|
233
|
+
out.push({ key: it.id, desc: { kind: 'user', text: it.text }, groupIndex: gi, tail: null });
|
|
234
|
+
}
|
|
235
|
+
else if (it.kind === 'text') {
|
|
236
|
+
out.push({ key: it.id, desc: { kind: 'assistant', text: it.text }, groupIndex: gi, tail: null });
|
|
237
|
+
}
|
|
238
|
+
else if (it.kind === 'error') {
|
|
239
|
+
out.push({ key: it.id, desc: { kind: 'error', message: it.message }, groupIndex: gi, tail: null });
|
|
240
|
+
}
|
|
241
|
+
else if (it.kind === 'policy') {
|
|
242
|
+
out.push({ key: it.id, desc: { kind: 'policy' }, groupIndex: gi, tail: null });
|
|
243
|
+
}
|
|
244
|
+
else if (it.kind === 'file_changed') {
|
|
245
|
+
out.push({ key: it.id, desc: { kind: 'file', path: it.path }, groupIndex: gi, tail: null });
|
|
246
|
+
}
|
|
247
|
+
else if (it.kind === 'diff') {
|
|
248
|
+
out.push({
|
|
249
|
+
key: it.id,
|
|
250
|
+
desc: { kind: 'diff', hunks: it.hunks.map((h) => ({ path: h.path, lines: h.lines.map((l) => l.text) })) },
|
|
251
|
+
groupIndex: gi,
|
|
252
|
+
tail: null,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (plan.length > 0) {
|
|
257
|
+
out.push({
|
|
258
|
+
key: 'tail:plan',
|
|
259
|
+
desc: { kind: 'plan', done: plan.filter((p) => p.status === 'done').length, total: plan.length },
|
|
260
|
+
groupIndex: null,
|
|
261
|
+
tail: 'plan',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (status.status === 'running' && !streamingIdRef.current) {
|
|
265
|
+
out.push({ key: 'tail:thinking', desc: { kind: 'thinking' }, groupIndex: null, tail: 'thinking' });
|
|
266
|
+
}
|
|
267
|
+
if (queuedInputs.length > 0) {
|
|
268
|
+
out.push({ key: 'tail:queued', desc: { kind: 'queued', count: queuedInputs.length }, groupIndex: null, tail: 'queued' });
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
272
|
+
}, [grouped, plan, status.status, queuedInputs.length, expandedGroups, width, transcript]);
|
|
273
|
+
const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
|
|
274
|
+
const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
|
|
275
|
+
const scroll = useChatScroll({ keys: blockKeys, heights: blockHeights, viewportH, width });
|
|
276
|
+
const { topRow, maxTop, pinned, pendingNew, commands } = scroll;
|
|
200
277
|
const trackH = viewportH;
|
|
201
|
-
const thumbPos =
|
|
202
|
-
|
|
278
|
+
const thumbPos = maxTop === 0 ? 0 : Math.round((topRow / maxTop) * (trackH - 1));
|
|
279
|
+
// Slice *display lines* [topRow, topRow+viewportH): intersecting blocks only
|
|
280
|
+
// (§6.1 virtualization — only visible items are composed).
|
|
281
|
+
const endRow = topRow + viewportH;
|
|
282
|
+
let gi0 = grouped.length;
|
|
283
|
+
let gi1 = -1;
|
|
284
|
+
let showPlan = false;
|
|
285
|
+
let showThinking = false;
|
|
286
|
+
let showQueued = false;
|
|
287
|
+
if (!isFullscreen || tiny) {
|
|
288
|
+
gi0 = 0;
|
|
289
|
+
gi1 = grouped.length - 1;
|
|
290
|
+
showPlan = true;
|
|
291
|
+
showThinking = true;
|
|
292
|
+
showQueued = true;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
let row = 0;
|
|
296
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
297
|
+
const h = blockHeights[bi] ?? 0;
|
|
298
|
+
const bStart = row;
|
|
299
|
+
const bEnd = row + h;
|
|
300
|
+
row = bEnd;
|
|
301
|
+
if (bEnd <= topRow || bStart >= endRow || h === 0)
|
|
302
|
+
continue;
|
|
303
|
+
const b = blocks[bi];
|
|
304
|
+
if (b.groupIndex !== null) {
|
|
305
|
+
gi0 = Math.min(gi0, b.groupIndex);
|
|
306
|
+
gi1 = Math.max(gi1, b.groupIndex);
|
|
307
|
+
}
|
|
308
|
+
else if (b.tail === 'plan')
|
|
309
|
+
showPlan = true;
|
|
310
|
+
else if (b.tail === 'thinking')
|
|
311
|
+
showThinking = true;
|
|
312
|
+
else if (b.tail === 'queued')
|
|
313
|
+
showQueued = true;
|
|
314
|
+
}
|
|
315
|
+
if (gi1 < gi0) {
|
|
316
|
+
gi0 = 0;
|
|
317
|
+
gi1 = -1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const visibleGrouped = isFullscreen && !tiny ? grouped.slice(gi0, gi1 + 1) : grouped;
|
|
203
321
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
204
322
|
useEffect(() => {
|
|
205
323
|
if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
|
|
@@ -259,7 +377,7 @@ export function App(props) {
|
|
|
259
377
|
return;
|
|
260
378
|
}
|
|
261
379
|
// Scroll keys (work in any mode, including while running).
|
|
262
|
-
if (isFullscreen &&
|
|
380
|
+
if (isFullscreen && maxTop > 0) {
|
|
263
381
|
if (key.home) {
|
|
264
382
|
commands.jumpTop();
|
|
265
383
|
return;
|
|
@@ -268,6 +386,10 @@ export function App(props) {
|
|
|
268
386
|
commands.jumpBottom();
|
|
269
387
|
return;
|
|
270
388
|
}
|
|
389
|
+
if (key.ctrl && inputStr === 'g') {
|
|
390
|
+
commands.jumpBottom();
|
|
391
|
+
return;
|
|
392
|
+
} // Ctrl+G → bottom (§8.4)
|
|
271
393
|
if (key.pageUp || (key.ctrl && inputStr === 'u')) {
|
|
272
394
|
commands.pageUp();
|
|
273
395
|
return;
|
|
@@ -299,14 +421,19 @@ export function App(props) {
|
|
|
299
421
|
void props.onSlash({ kind: 'quit' });
|
|
300
422
|
return;
|
|
301
423
|
}
|
|
424
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
302
425
|
if (key.return) {
|
|
303
426
|
const v = input.trim();
|
|
304
|
-
if (!v)
|
|
427
|
+
if (!v) {
|
|
428
|
+
if (pinned)
|
|
429
|
+
commands.jumpBottom();
|
|
305
430
|
return;
|
|
431
|
+
}
|
|
306
432
|
if (queuedInputs.length >= 3)
|
|
307
433
|
return;
|
|
308
434
|
setQueuedInputs((prev) => [...prev, v]);
|
|
309
435
|
setInput('');
|
|
436
|
+
pushHistory(v);
|
|
310
437
|
return;
|
|
311
438
|
}
|
|
312
439
|
if (key.backspace || key.delete) {
|
|
@@ -317,11 +444,53 @@ export function App(props) {
|
|
|
317
444
|
setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
318
445
|
return;
|
|
319
446
|
}
|
|
447
|
+
// Contextual ↑/↓ (§8.3): text in buffer (or browsing) → history;
|
|
448
|
+
// empty buffer → scroll viewport one line.
|
|
449
|
+
if (key.upArrow && !key.shift && !key.ctrl) {
|
|
450
|
+
if (input.trim() !== '' || histIdx !== null) {
|
|
451
|
+
if (history.length > 0) {
|
|
452
|
+
const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
453
|
+
setHistIdx(next);
|
|
454
|
+
setInput(history[next] ?? '');
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (isFullscreen && maxTop > 0) {
|
|
459
|
+
commands.lineUp();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (key.downArrow && !key.shift && !key.ctrl) {
|
|
464
|
+
if (histIdx !== null) {
|
|
465
|
+
const next = histIdx + 1;
|
|
466
|
+
if (next >= history.length) {
|
|
467
|
+
setHistIdx(null);
|
|
468
|
+
setInput('');
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
setHistIdx(next);
|
|
472
|
+
setInput(history[next] ?? '');
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (input.trim() !== '')
|
|
477
|
+
return; // single line with text, nothing newer
|
|
478
|
+
if (isFullscreen && maxTop > 0) {
|
|
479
|
+
commands.lineDown();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
320
484
|
if (key.return) {
|
|
321
485
|
const v = input.trim();
|
|
322
|
-
if (!v)
|
|
486
|
+
if (!v) {
|
|
487
|
+
if (pinned)
|
|
488
|
+
commands.jumpBottom();
|
|
323
489
|
return;
|
|
490
|
+
}
|
|
324
491
|
setInput('');
|
|
492
|
+
setHistIdx(null);
|
|
493
|
+
pushHistory(v);
|
|
325
494
|
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
326
495
|
streamingIdRef.current = null;
|
|
327
496
|
const cmd = parseSlash(v);
|
|
@@ -333,10 +502,13 @@ export function App(props) {
|
|
|
333
502
|
}
|
|
334
503
|
if (key.backspace || key.delete) {
|
|
335
504
|
setInput((v) => v.slice(0, -1));
|
|
505
|
+
setHistIdx(null);
|
|
336
506
|
return;
|
|
337
507
|
}
|
|
338
|
-
if (!key.ctrl && !key.meta)
|
|
508
|
+
if (!key.ctrl && !key.meta) {
|
|
339
509
|
setInput((v) => v + inputStr);
|
|
510
|
+
setHistIdx(null);
|
|
511
|
+
}
|
|
340
512
|
});
|
|
341
513
|
const ver = props.version ?? '0.1.27';
|
|
342
514
|
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
@@ -344,8 +516,8 @@ export function App(props) {
|
|
|
344
516
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
345
517
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
|
|
346
518
|
const baseHints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
|
|
347
|
-
const hints =
|
|
348
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : visibleGrouped.map((item) => {
|
|
519
|
+
const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
|
|
520
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [tiny ? (_jsxs(Text, { color: tokens.colors.warn, children: ["\u26A0 terminal too small (", width, "x", height, ") \u2014 transcript hidden"] })) : null, !tiny && grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : !tiny ? visibleGrouped.map((item) => {
|
|
349
521
|
if (item.verb) {
|
|
350
522
|
const gr = item;
|
|
351
523
|
const isExpanded = expandedGroups.has(gr.id);
|
|
@@ -381,26 +553,26 @@ export function App(props) {
|
|
|
381
553
|
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
|
|
382
554
|
const marker = isExpanded ? g('expanded') : g('collapsed');
|
|
383
555
|
const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
|
|
384
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? gr.items.map((it) => {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
556
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? (_jsxs(_Fragment, { children: [gr.items.slice(0, 12).map((it) => {
|
|
557
|
+
let friendly = '';
|
|
558
|
+
try {
|
|
559
|
+
const a = JSON.parse(it.args);
|
|
560
|
+
const p = a.path ?? a.pattern ?? a.command ?? '';
|
|
561
|
+
const short = p ? String(p).split('/').pop()?.slice(0, 40) ?? p : '';
|
|
562
|
+
if (it.name === 'read_file' && short)
|
|
563
|
+
friendly = `${short}`;
|
|
564
|
+
else if (it.name === 'shell_exec' && p)
|
|
565
|
+
friendly = `$ ${String(p).slice(0, 40)}`;
|
|
566
|
+
else if (short)
|
|
567
|
+
friendly = short;
|
|
568
|
+
else
|
|
569
|
+
friendly = it.args.slice(0, 40);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
friendly = it.args.slice(0, 40);
|
|
573
|
+
}
|
|
574
|
+
return (_jsxs(Box, { paddingLeft: 4, children: [_jsxs(Text, { color: tokens.colors.guide, children: [g('end'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: friendly })] }, it.id));
|
|
575
|
+
}), gr.items.length > 12 ? (_jsx(Box, { paddingLeft: 4, children: _jsxs(Text, { color: tokens.colors.dim, children: ["\u2026 ", gr.items.length - 12, " more"] }) })) : null] })) : null] }, gr.id));
|
|
404
576
|
}
|
|
405
577
|
const it = item;
|
|
406
578
|
if (it.kind === 'text' && it.role === 'user') {
|
|
@@ -419,5 +591,5 @@ export function App(props) {
|
|
|
419
591
|
if (it.kind === 'diff')
|
|
420
592
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
|
|
421
593
|
return null;
|
|
422
|
-
}), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew
|
|
594
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
|
|
423
595
|
}
|
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