klyro 0.1.40 → 0.1.41
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/tui/app.js +113 -24
- package/dist/tui/app.test.js +142 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
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
|
|
5
5
|
*/
|
|
6
|
-
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
6
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
8
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
9
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
@@ -92,6 +92,79 @@ function MarkdownText({ text, dim, width }) {
|
|
|
92
92
|
// Render as single line with bold segments — Ink will wrap the parent Box
|
|
93
93
|
return _jsx(Text, { wrap: "wrap", children: parts });
|
|
94
94
|
}
|
|
95
|
+
// Chat scroll state: scrollOffset, pinned (user scrolled away from bottom),
|
|
96
|
+
// pendingNew (rows arrived while pinned), and a commands bag for key handlers.
|
|
97
|
+
// The `tick` prop is a monotonic value that increments on every content mutation,
|
|
98
|
+
// including in-place text growth during streaming (appendDelta mutates by index,
|
|
99
|
+
// so transcript.length does not change on a delta — the effect must fire anyway).
|
|
100
|
+
function useChatScroll(opts) {
|
|
101
|
+
const { totalRows, viewportH, messageBoundaries, tick } = opts;
|
|
102
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
103
|
+
const [pinned, setPinned] = useState(false);
|
|
104
|
+
const [pendingNew, setPendingNew] = useState(0);
|
|
105
|
+
const pinnedRef = useRef(false);
|
|
106
|
+
const maxOffset = Math.max(0, totalRows - viewportH);
|
|
107
|
+
// 1-line tolerance: maxOffset can shift by 1 during streaming and leave us
|
|
108
|
+
// at maxOffset - 1, which would otherwise be "not at bottom". The +1 tolerance
|
|
109
|
+
// keeps follow-tail engaged through that off-by-one.
|
|
110
|
+
const isAtBottom = scrollOffset + 1 >= maxOffset;
|
|
111
|
+
const recomputePinned = useCallback((next) => {
|
|
112
|
+
const atBottom = next + 1 >= maxOffset;
|
|
113
|
+
pinnedRef.current = !atBottom;
|
|
114
|
+
setPinned(!atBottom);
|
|
115
|
+
if (atBottom)
|
|
116
|
+
setPendingNew(0);
|
|
117
|
+
}, [maxOffset]);
|
|
118
|
+
// Watch `tick` — fires on every content change (add, remove, in-place delta).
|
|
119
|
+
const lastTickRef = useRef(tick);
|
|
120
|
+
const lastMaxOffsetRef = useRef(maxOffset);
|
|
121
|
+
const firstEffectRef = useRef(true);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (firstEffectRef.current) {
|
|
124
|
+
// Initial mount: if there's content, follow the tail (preserves the
|
|
125
|
+
// pre-refactor behavior where scrollOffset was 0 only on empty state).
|
|
126
|
+
firstEffectRef.current = false;
|
|
127
|
+
lastTickRef.current = tick;
|
|
128
|
+
lastMaxOffsetRef.current = maxOffset;
|
|
129
|
+
if (maxOffset > 0) {
|
|
130
|
+
setScrollOffset(maxOffset);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (tick === lastTickRef.current)
|
|
135
|
+
return;
|
|
136
|
+
lastTickRef.current = tick;
|
|
137
|
+
const grew = maxOffset - lastMaxOffsetRef.current;
|
|
138
|
+
lastMaxOffsetRef.current = maxOffset;
|
|
139
|
+
if (pinnedRef.current) {
|
|
140
|
+
if (grew > 0)
|
|
141
|
+
setPendingNew((p) => p + grew);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// FollowTail: snap to the new bottom.
|
|
145
|
+
setScrollOffset(maxOffset);
|
|
146
|
+
}
|
|
147
|
+
}, [tick, maxOffset]);
|
|
148
|
+
const commands = {
|
|
149
|
+
lineUp: () => { const next = Math.max(0, scrollOffset - 1); setScrollOffset(next); recomputePinned(next); },
|
|
150
|
+
lineDown: () => { const next = Math.min(maxOffset, scrollOffset + 1); setScrollOffset(next); recomputePinned(next); },
|
|
151
|
+
pageUp: () => {
|
|
152
|
+
const prev = [...messageBoundaries].reverse().find((b) => b < scrollOffset);
|
|
153
|
+
const next = prev ?? Math.max(0, scrollOffset - viewportH);
|
|
154
|
+
setScrollOffset(next);
|
|
155
|
+
recomputePinned(next);
|
|
156
|
+
},
|
|
157
|
+
pageDown: () => {
|
|
158
|
+
const nxt = messageBoundaries.find((b) => b > scrollOffset);
|
|
159
|
+
const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
|
|
160
|
+
setScrollOffset(next);
|
|
161
|
+
recomputePinned(next);
|
|
162
|
+
},
|
|
163
|
+
jumpTop: () => { setScrollOffset(0); recomputePinned(0); },
|
|
164
|
+
jumpBottom: () => { setScrollOffset(maxOffset); recomputePinned(maxOffset); },
|
|
165
|
+
};
|
|
166
|
+
return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
|
|
167
|
+
}
|
|
95
168
|
export function App(props) {
|
|
96
169
|
const { stdout } = useStdout();
|
|
97
170
|
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
@@ -103,7 +176,6 @@ export function App(props) {
|
|
|
103
176
|
const [elapsed, setElapsed] = useState(0);
|
|
104
177
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
105
178
|
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
106
|
-
const [scrollOffset, setScrollOffset] = useState(0);
|
|
107
179
|
const streamingIdRef = useRef(null);
|
|
108
180
|
const width = stdout?.columns ?? 100;
|
|
109
181
|
const height = stdout?.rows ?? 30;
|
|
@@ -111,8 +183,18 @@ export function App(props) {
|
|
|
111
183
|
const grouped = groupTools(transcript);
|
|
112
184
|
const viewportH = Math.max(5, height - 10);
|
|
113
185
|
const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
|
|
114
|
-
const
|
|
115
|
-
|
|
186
|
+
const messageBoundaries = useMemo(() => grouped.map((_, i) => i), [grouped]);
|
|
187
|
+
// Monotonic tick: increments on every render, so the scroll hook fires
|
|
188
|
+
// for every content mutation — including in-place text deltas.
|
|
189
|
+
const tickRef = useRef(0);
|
|
190
|
+
useEffect(() => { tickRef.current += 1; });
|
|
191
|
+
const scroll = useChatScroll({
|
|
192
|
+
totalRows,
|
|
193
|
+
viewportH,
|
|
194
|
+
messageBoundaries,
|
|
195
|
+
tick: tickRef.current,
|
|
196
|
+
});
|
|
197
|
+
const { scrollOffset, isAtBottom, maxOffset, pinned, pendingNew, commands } = scroll;
|
|
116
198
|
const trackH = viewportH;
|
|
117
199
|
const thumbPos = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
|
|
118
200
|
const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
|
|
@@ -132,8 +214,6 @@ export function App(props) {
|
|
|
132
214
|
}, [queuedInputs, status.status, awaitingApproval]);
|
|
133
215
|
useEffect(() => { if (status.status !== 'running')
|
|
134
216
|
return; const start = Date.now() - elapsed; const t = setInterval(() => setElapsed(Date.now() - start), 1000); return () => clearInterval(t); }, [status.status, elapsed]);
|
|
135
|
-
useEffect(() => { if (isAtBottom)
|
|
136
|
-
setScrollOffset(maxOffset); }, [transcript.length, plan.length, maxOffset, isAtBottom]);
|
|
137
217
|
const append = useCallback((item) => { if (item.kind !== 'text' || item.role !== 'assistant')
|
|
138
218
|
streamingIdRef.current = null; setTranscript((prev) => [...prev, item]); }, []);
|
|
139
219
|
const appendDelta = useCallback((text) => {
|
|
@@ -160,28 +240,37 @@ export function App(props) {
|
|
|
160
240
|
n.delete(id);
|
|
161
241
|
else
|
|
162
242
|
n.add(id); return n; });
|
|
163
|
-
const scrollUp = (n = 3) => setScrollOffset((p) => Math.max(0, p - n));
|
|
164
|
-
const scrollDown = (n = 3) => setScrollOffset((p) => Math.min(maxOffset, p + n));
|
|
165
243
|
useInput((inputStr, key) => {
|
|
166
244
|
if (key.escape && queuedInputs.length > 0) {
|
|
167
245
|
setQueuedInputs((prev) => prev.slice(1));
|
|
168
246
|
return;
|
|
169
247
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
248
|
+
// Scroll keys (work in any mode, including while running).
|
|
249
|
+
if (isFullscreen && maxOffset > 0) {
|
|
250
|
+
if (key.home) {
|
|
251
|
+
commands.jumpTop();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (key.end) {
|
|
255
|
+
commands.jumpBottom();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (key.pageUp || (key.ctrl && inputStr === 'u')) {
|
|
259
|
+
commands.pageUp();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (key.pageDown || (key.ctrl && inputStr === 'd')) {
|
|
263
|
+
commands.pageDown();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (key.upArrow && (key.shift || key.ctrl)) {
|
|
267
|
+
commands.lineUp();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (key.downArrow && (key.shift || key.ctrl)) {
|
|
271
|
+
commands.lineDown();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
185
274
|
}
|
|
186
275
|
if (awaitingApproval)
|
|
187
276
|
return;
|
|
@@ -317,5 +406,5 @@ export function App(props) {
|
|
|
317
406
|
if (it.kind === 'diff')
|
|
318
407
|
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));
|
|
319
408
|
return null;
|
|
320
|
-
}), 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] }), _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\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _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, maxOffset > 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 â—' : ''] })] })] }));
|
|
409
|
+
}), 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, ' new ', pendingNew === 1 ? 'message' : 'messages', ' '] }) })) : 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\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _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, maxOffset > 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 â—' : ''] })] })] }));
|
|
321
410
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -79,4 +79,146 @@ describe('App', () => {
|
|
|
79
79
|
const call = onSlash.mock.calls[0]?.[0];
|
|
80
80
|
expect(call?.kind).toBe('quit');
|
|
81
81
|
});
|
|
82
|
+
// --- Chat scroll behavior (TUI_DESIGN chat_scroll.md) -----------------
|
|
83
|
+
// Build a 25-item initial transcript. Each item has a unique tag so we can
|
|
84
|
+
// grep `lastFrame()` for it.
|
|
85
|
+
function makeInitialTranscript(n) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
out.push({
|
|
89
|
+
id: `seed-${i}`,
|
|
90
|
+
kind: 'text',
|
|
91
|
+
text: `MSG-${i.toString().padStart(2, '0')}-tag`,
|
|
92
|
+
role: 'user',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
// ANSI sequences Ink's parse-keypress recognizes.
|
|
98
|
+
const KEY_HOME = '\x1b[H';
|
|
99
|
+
const KEY_END = '\x1b[F';
|
|
100
|
+
const KEY_PGUP = '\x1b[5~';
|
|
101
|
+
const KEY_PGDN = '\x1b[6~';
|
|
102
|
+
const KEY_SHIFT_UP = '\x1b[1;2A';
|
|
103
|
+
const KEY_SHIFT_DOWN = '\x1b[1;2B';
|
|
104
|
+
it('starts at the bottom (follow-tail) when initial content fills the viewport', async () => {
|
|
105
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
106
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
107
|
+
const frame = lastFrame() ?? '';
|
|
108
|
+
// The viewport is 20 rows; the last few seeded items (MSG-22..MSG-24) should
|
|
109
|
+
// be in the visible window. The first item (MSG-00) should NOT be visible.
|
|
110
|
+
expect(frame).toMatch(/MSG-24-tag/);
|
|
111
|
+
expect(frame).toMatch(/MSG-23-tag/);
|
|
112
|
+
expect(frame).not.toMatch(/MSG-00-tag/);
|
|
113
|
+
});
|
|
114
|
+
it('Home jumps to the top; End re-engages follow-tail', async () => {
|
|
115
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
116
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
117
|
+
stdin.write(KEY_HOME);
|
|
118
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
119
|
+
const top = lastFrame() ?? '';
|
|
120
|
+
expect(top).toMatch(/MSG-00-tag/);
|
|
121
|
+
expect(top).not.toMatch(/MSG-24-tag/);
|
|
122
|
+
// End re-engages follow-tail.
|
|
123
|
+
stdin.write(KEY_END);
|
|
124
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
125
|
+
const bottom = lastFrame() ?? '';
|
|
126
|
+
expect(bottom).toMatch(/MSG-24-tag/);
|
|
127
|
+
expect(bottom).not.toMatch(/MSG-00-tag/);
|
|
128
|
+
});
|
|
129
|
+
it('PageUp/PageDown snap to message boundaries', async () => {
|
|
130
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
131
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
132
|
+
// Go to top, then PageDown 3 times. Each PageDown should land on a message
|
|
133
|
+
// boundary, so visible window starts at one of the seeded indices.
|
|
134
|
+
stdin.write(KEY_HOME);
|
|
135
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
136
|
+
stdin.write(KEY_PGDN);
|
|
137
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
138
|
+
stdin.write(KEY_PGDN);
|
|
139
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
140
|
+
stdin.write(KEY_PGDN);
|
|
141
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
142
|
+
const frame = lastFrame() ?? '';
|
|
143
|
+
// After 3 PageDowns from top, the earliest visible item should be MSG-03
|
|
144
|
+
// (snap-to-message keeps the boundary on the first visible row). We assert
|
|
145
|
+
// that MSG-03 is visible and MSG-00 is not.
|
|
146
|
+
expect(frame).toMatch(/MSG-03-tag/);
|
|
147
|
+
expect(frame).not.toMatch(/MSG-00-tag/);
|
|
148
|
+
});
|
|
149
|
+
it('pins to top: new content does NOT auto-scroll when user has scrolled up', async () => {
|
|
150
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
151
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
152
|
+
// Pin: scroll up to top.
|
|
153
|
+
stdin.write(KEY_HOME);
|
|
154
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
155
|
+
const before = lastFrame() ?? '';
|
|
156
|
+
expect(before).toMatch(/MSG-00-tag/);
|
|
157
|
+
expect(before).not.toMatch(/MSG-24-tag/);
|
|
158
|
+
// New content arrives while pinned.
|
|
159
|
+
const g = globalThis;
|
|
160
|
+
g.__klyroAppAppend({
|
|
161
|
+
id: 'late-1',
|
|
162
|
+
kind: 'text',
|
|
163
|
+
text: 'LATE-1-tag',
|
|
164
|
+
role: 'assistant',
|
|
165
|
+
});
|
|
166
|
+
g.__klyroAppAppend({
|
|
167
|
+
id: 'late-2',
|
|
168
|
+
kind: 'text',
|
|
169
|
+
text: 'LATE-2-tag',
|
|
170
|
+
role: 'assistant',
|
|
171
|
+
});
|
|
172
|
+
g.__klyroAppAppend({
|
|
173
|
+
id: 'late-3',
|
|
174
|
+
kind: 'text',
|
|
175
|
+
text: 'LATE-3-tag',
|
|
176
|
+
role: 'assistant',
|
|
177
|
+
});
|
|
178
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
179
|
+
const after = lastFrame() ?? '';
|
|
180
|
+
// Still pinned at top: MSG-00 visible, LATE items not in viewport.
|
|
181
|
+
expect(after).toMatch(/MSG-00-tag/);
|
|
182
|
+
expect(after).not.toMatch(/LATE-1-tag/);
|
|
183
|
+
});
|
|
184
|
+
it('pressing End re-engages follow-tail and reveals new content', async () => {
|
|
185
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
186
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
187
|
+
stdin.write(KEY_HOME);
|
|
188
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
189
|
+
const g = globalThis;
|
|
190
|
+
g.__klyroAppAppend({
|
|
191
|
+
id: 'late-1',
|
|
192
|
+
kind: 'text',
|
|
193
|
+
text: 'LATE-1-tag',
|
|
194
|
+
role: 'assistant',
|
|
195
|
+
});
|
|
196
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
197
|
+
expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
|
|
198
|
+
// End re-engages follow-tail and shows the new content.
|
|
199
|
+
stdin.write(KEY_END);
|
|
200
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
201
|
+
const frame = lastFrame() ?? '';
|
|
202
|
+
expect(frame).toMatch(/LATE-1-tag/);
|
|
203
|
+
});
|
|
204
|
+
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
205
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
206
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
207
|
+
// Jump to top, then shift+down a few times, then back with shift+up.
|
|
208
|
+
stdin.write(KEY_HOME);
|
|
209
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
210
|
+
stdin.write(KEY_SHIFT_DOWN);
|
|
211
|
+
stdin.write(KEY_SHIFT_DOWN);
|
|
212
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
213
|
+
const frame = lastFrame() ?? '';
|
|
214
|
+
// Shift+Down from scrollOffset=0 moves us down by 2. The visible window
|
|
215
|
+
// is now [2..22). MSG-00 should be off-screen, MSG-02 should be on-screen.
|
|
216
|
+
expect(frame).not.toMatch(/MSG-00-tag/);
|
|
217
|
+
expect(frame).toMatch(/MSG-02-tag/);
|
|
218
|
+
// Shift+Up once: scrollOffset back to 1, MSG-01 visible, MSG-02 still visible.
|
|
219
|
+
stdin.write(KEY_SHIFT_UP);
|
|
220
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
221
|
+
const frame2 = lastFrame() ?? '';
|
|
222
|
+
expect(frame2).toMatch(/MSG-01-tag/);
|
|
223
|
+
});
|
|
82
224
|
});
|