klyro 0.1.50 → 0.1.52
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.d.ts +2 -0
- package/dist/tui/app.js +57 -19
- package/dist/tui/app.test.js +37 -0
- package/dist/tui/snapshot.test.js +11 -0
- package/dist/tui/transcript-commands.d.ts +34 -0
- package/dist/tui/transcript-commands.js +32 -0
- package/package.json +1 -1
package/dist/tui/app.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { StatusSnapshot } from './status.js';
|
|
|
7
7
|
import type { TranscriptItem } from './transcript.js';
|
|
8
8
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
9
|
import type { PlanStep } from '../agent/runtime.js';
|
|
10
|
+
import { type TranscriptScrollHandle } from './transcript-commands.js';
|
|
10
11
|
export interface AppProps {
|
|
11
12
|
initialModel: string;
|
|
12
13
|
maxSteps: number;
|
|
@@ -26,6 +27,7 @@ export interface AppProps {
|
|
|
26
27
|
scrollToBottom: () => void;
|
|
27
28
|
scrollHalfPage: (dir: -1 | 1) => void;
|
|
28
29
|
scrollToTop: () => void;
|
|
30
|
+
transcript: TranscriptScrollHandle;
|
|
29
31
|
}) => void;
|
|
30
32
|
version?: string;
|
|
31
33
|
isFullscreen?: boolean;
|
package/dist/tui/app.js
CHANGED
|
@@ -3,7 +3,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
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, useMemo } from 'react';
|
|
6
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
@@ -12,6 +12,7 @@ import { tokens, g } from './tokens.js';
|
|
|
12
12
|
import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
|
|
13
13
|
import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
|
|
14
14
|
import { renderMarkdownLines } from './markdown.js';
|
|
15
|
+
import { getTranscriptCommand, } from './transcript-commands.js';
|
|
15
16
|
let _id = 0;
|
|
16
17
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
17
18
|
function Header({ cwd, model, version, width }) {
|
|
@@ -82,13 +83,17 @@ function groupTools(items) {
|
|
|
82
83
|
return out;
|
|
83
84
|
}
|
|
84
85
|
// design.md §23/§24 — terminal Markdown via tui/markdown.ts: headings,
|
|
85
|
-
// **bold**, *italic*, `code`, fences, links, lists. Ink wraps
|
|
86
|
+
// **bold**, *italic*, `code`, fences, links, lists. Ink wraps the text.
|
|
87
|
+
//
|
|
88
|
+
// CRITICAL: this must return a SINGLE <Text> with inline nested parts.
|
|
89
|
+
// A fragment of sibling <Text>s inside the row-direction parent Box lays
|
|
90
|
+
// out as side-by-side COLUMNS (garbled transcript) instead of lines.
|
|
86
91
|
function MarkdownText({ text, dim, width }) {
|
|
87
92
|
void width;
|
|
88
93
|
const lines = useMemo(() => renderMarkdownLines(text), [text]);
|
|
89
94
|
const dimColor = tokens.colors.dim;
|
|
90
95
|
const softColor = tokens.colors.soft;
|
|
91
|
-
return (_jsx(
|
|
96
|
+
return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => (_jsx(Text, { bold: p.bold || undefined, color: p.bold ? (dim ? undefined : softColor) : p.dim ? dimColor : p.code ? softColor : undefined, children: p.text }, j)))] }, i))) }));
|
|
92
97
|
}
|
|
93
98
|
// Chat scroll — scroll.md §5 anchor model adapted to Ink.
|
|
94
99
|
//
|
|
@@ -184,13 +189,21 @@ export function App(props) {
|
|
|
184
189
|
if (status.status !== 'running')
|
|
185
190
|
ctrlCArmed.current = false;
|
|
186
191
|
}, [status.status]);
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
// scroll.md §7: submit bumps a version key; the settle effect below pins
|
|
193
|
+
// to bottom immediate + microtask + timeout so layout settles first.
|
|
194
|
+
// (Decoupled from render: never scroll directly inside the submit handler.)
|
|
195
|
+
const [submitKey, setSubmitKey] = useState(0);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!submitKey)
|
|
198
|
+
return;
|
|
199
|
+
const toBottom = () => {
|
|
200
|
+
scrollCmdsRef.current.bottom();
|
|
201
|
+
};
|
|
202
|
+
toBottom();
|
|
203
|
+
queueMicrotask(toBottom);
|
|
204
|
+
const t = setTimeout(toBottom, 0);
|
|
205
|
+
return () => clearTimeout(t);
|
|
206
|
+
}, [submitKey]);
|
|
194
207
|
// Shift+Enter intent: explicit shift+return, kitty/CSI-u sequence, legacy
|
|
195
208
|
// ESC+CR pair, or Esc immediately followed by Return (75ms, non-empty input).
|
|
196
209
|
const escReturnAt = useRef(0);
|
|
@@ -383,9 +396,28 @@ export function App(props) {
|
|
|
383
396
|
const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
|
|
384
397
|
const scrollHalfPage = useCallback((dir) => { scrollCmdsRef.current.halfPage(dir); }, []);
|
|
385
398
|
const scrollToTop = useCallback(() => { scrollCmdsRef.current.top(); }, []);
|
|
399
|
+
// scroll.md §2/§10: the four-command TranscriptScrollHandle.
|
|
400
|
+
const transcriptHandle = useMemo(() => ({
|
|
401
|
+
runTranscriptCommand: (command) => {
|
|
402
|
+
switch (command) {
|
|
403
|
+
case 'messages_half_page_up':
|
|
404
|
+
scrollCmdsRef.current.halfPage(-1);
|
|
405
|
+
return;
|
|
406
|
+
case 'messages_half_page_down':
|
|
407
|
+
scrollCmdsRef.current.halfPage(1);
|
|
408
|
+
return;
|
|
409
|
+
case 'messages_first':
|
|
410
|
+
scrollCmdsRef.current.top();
|
|
411
|
+
return;
|
|
412
|
+
case 'messages_last':
|
|
413
|
+
scrollCmdsRef.current.bottom();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
}), []);
|
|
386
418
|
const onMountedRef = useRef(props.onMounted);
|
|
387
419
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
388
|
-
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop]);
|
|
420
|
+
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle]);
|
|
389
421
|
const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
|
|
390
422
|
n.delete(id);
|
|
391
423
|
else
|
|
@@ -406,26 +438,32 @@ export function App(props) {
|
|
|
406
438
|
return;
|
|
407
439
|
}
|
|
408
440
|
// Scroll keys (work in any mode, including while running).
|
|
441
|
+
// scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
|
|
409
442
|
// design.md §11: PageUp/Ctrl+U half-up, PageDown/Ctrl+D half-down,
|
|
410
|
-
// Ctrl+Home/Ctrl+End first/last. (Ctrl+B/F kept
|
|
443
|
+
// Ctrl+Home/Ctrl+End first/last. (Plain Home/End + Ctrl+B/F/G kept.)
|
|
411
444
|
if (isFullscreen && maxTop > 0) {
|
|
445
|
+
const tcmd = getTranscriptCommand(inputStr, key);
|
|
446
|
+
if (tcmd) {
|
|
447
|
+
transcriptHandle.runTranscriptCommand(tcmd);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
412
450
|
if (key.home) {
|
|
413
451
|
commands.jumpTop();
|
|
414
452
|
return;
|
|
415
|
-
}
|
|
453
|
+
}
|
|
416
454
|
if (key.end) {
|
|
417
455
|
commands.jumpBottom();
|
|
418
456
|
return;
|
|
419
|
-
}
|
|
457
|
+
}
|
|
420
458
|
if (key.ctrl && inputStr === 'g') {
|
|
421
459
|
commands.jumpBottom();
|
|
422
460
|
return;
|
|
423
461
|
} // Ctrl+G → bottom
|
|
424
|
-
if (
|
|
462
|
+
if ((key.ctrl && inputStr === 'b')) {
|
|
425
463
|
commands.pageUp();
|
|
426
464
|
return;
|
|
427
465
|
}
|
|
428
|
-
if (
|
|
466
|
+
if ((key.ctrl && inputStr === 'f')) {
|
|
429
467
|
commands.pageDown();
|
|
430
468
|
return;
|
|
431
469
|
}
|
|
@@ -490,7 +528,7 @@ export function App(props) {
|
|
|
490
528
|
setQueuedInputs((prev) => [...prev, v]);
|
|
491
529
|
setInput('');
|
|
492
530
|
pushHistory(v);
|
|
493
|
-
|
|
531
|
+
setSubmitKey((k) => k + 1);
|
|
494
532
|
return;
|
|
495
533
|
}
|
|
496
534
|
if (key.backspace || key.delete) {
|
|
@@ -555,7 +593,7 @@ export function App(props) {
|
|
|
555
593
|
pushHistory(v);
|
|
556
594
|
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
557
595
|
streamingIdRef.current = null;
|
|
558
|
-
|
|
596
|
+
setSubmitKey((k) => k + 1);
|
|
559
597
|
const cmd = parseSlash(v);
|
|
560
598
|
if (cmd.kind === 'prompt')
|
|
561
599
|
void props.onPrompt(cmd.text);
|
|
@@ -654,5 +692,5 @@ export function App(props) {
|
|
|
654
692
|
if (it.kind === 'diff')
|
|
655
693
|
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));
|
|
656
694
|
return null;
|
|
657
|
-
}) : 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, i) => (_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'), " ", i + 1, ". ", 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, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
695
|
+
}) : 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... (esc to cancel)" }), _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, i) => (_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'), " ", i + 1, ". ", 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, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
658
696
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -319,6 +319,43 @@ describe('App', () => {
|
|
|
319
319
|
await new Promise((r) => setTimeout(r, 50));
|
|
320
320
|
expect(onPrompt).toHaveBeenCalledWith('line one\nline two');
|
|
321
321
|
});
|
|
322
|
+
it('submitting while pinned snaps back to bottom (scroll.md §7)', async () => {
|
|
323
|
+
const onPrompt = vi.fn(async () => { });
|
|
324
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
325
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
326
|
+
stdin.write(KEY_HOME);
|
|
327
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
328
|
+
expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
|
|
329
|
+
stdin.write('hello again');
|
|
330
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
331
|
+
stdin.write('\x0d');
|
|
332
|
+
// Settle effect: immediate + microtask + timeout(0) bottom pin.
|
|
333
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
334
|
+
expect(onPrompt).toHaveBeenCalledWith('hello again');
|
|
335
|
+
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
336
|
+
});
|
|
337
|
+
it('onMounted transcript handle runs the four commands (scroll.md §2)', async () => {
|
|
338
|
+
let handle = null;
|
|
339
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
|
|
340
|
+
handle = h.transcript;
|
|
341
|
+
} }));
|
|
342
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
343
|
+
expect(handle).not.toBeNull();
|
|
344
|
+
handle.runTranscriptCommand('messages_half_page_up');
|
|
345
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
346
|
+
let frame = lastFrame() ?? '';
|
|
347
|
+
// Half page (10 lines) up from bottom (row 30 → 20): MSG-24 gone, MSG-14 in view.
|
|
348
|
+
expect(frame).not.toMatch(/MSG-24-tag/);
|
|
349
|
+
expect(frame).toMatch(/MSG-14-tag/);
|
|
350
|
+
handle.runTranscriptCommand('messages_first');
|
|
351
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
352
|
+
frame = lastFrame() ?? '';
|
|
353
|
+
expect(frame).toMatch(/MSG-00-tag/);
|
|
354
|
+
expect(frame).not.toMatch(/MSG-24-tag/);
|
|
355
|
+
handle.runTranscriptCommand('messages_last');
|
|
356
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
357
|
+
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
358
|
+
});
|
|
322
359
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
323
360
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
324
361
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -82,6 +82,17 @@ describe('App visual snapshot', () => {
|
|
|
82
82
|
expect(frame).not.toContain('## Done');
|
|
83
83
|
expect(frame).not.toContain('`npm test`');
|
|
84
84
|
});
|
|
85
|
+
it('multiline assistant text renders as stacked lines, not columns', () => {
|
|
86
|
+
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
87
|
+
{ id: 'm1', kind: 'text', text: 'ZZZTOPLINE\nZZZBOTTOMLINE', role: 'assistant' },
|
|
88
|
+
] }));
|
|
89
|
+
const rows = (lastFrame() ?? '').split('\n');
|
|
90
|
+
const top = rows.findIndex((r) => r.includes('ZZZTOPLINE'));
|
|
91
|
+
const bottom = rows.findIndex((r) => r.includes('ZZZBOTTOMLINE'));
|
|
92
|
+
// Columns bug put both markers on the SAME row; correct render stacks them.
|
|
93
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
94
|
+
expect(bottom).toBeGreaterThan(top);
|
|
95
|
+
});
|
|
85
96
|
it('diff transcript item renders the diff box', () => {
|
|
86
97
|
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
|
87
98
|
{
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §§2/4/5/6 — TranscriptCommand abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Only four scroll commands exist (Cline parity):
|
|
5
|
+
* messages_half_page_up / messages_half_page_down /
|
|
6
|
+
* messages_first / messages_last
|
|
7
|
+
*
|
|
8
|
+
* Flow (§6): Keyboard → getTranscriptCommand() → TranscriptScrollHandle →
|
|
9
|
+
* ChatMessageList. Keys are matched in ONE place (the §5 binding table);
|
|
10
|
+
* the App routes everything else (history, autocomplete, approvals).
|
|
11
|
+
*
|
|
12
|
+
* Note on the stack: the doc's `<scrollbox stickyScroll>` is OpenTUI-only
|
|
13
|
+
* and this app renders with Ink, which has no scrollbox primitive — so the
|
|
14
|
+
* handle drives the measured anchor viewport (tui/scroll-model.ts) instead.
|
|
15
|
+
* The command vocabulary and all §21 behaviors are identical.
|
|
16
|
+
*/
|
|
17
|
+
export type TranscriptCommand = 'messages_half_page_up' | 'messages_half_page_down' | 'messages_first' | 'messages_last';
|
|
18
|
+
export interface TranscriptScrollHandle {
|
|
19
|
+
runTranscriptCommand(command: TranscriptCommand): void;
|
|
20
|
+
}
|
|
21
|
+
/** Minimal Ink key shape needed for the binding table. */
|
|
22
|
+
export interface TranscriptKey {
|
|
23
|
+
pageUp?: boolean;
|
|
24
|
+
pageDown?: boolean;
|
|
25
|
+
home?: boolean;
|
|
26
|
+
end?: boolean;
|
|
27
|
+
ctrl?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* scroll.md §5 binding table (pure — unit-tested):
|
|
31
|
+
* PageUp / Ctrl+U → up · PageDown / Ctrl+D → down ·
|
|
32
|
+
* Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getTranscriptCommand(input: string, key: TranscriptKey): TranscriptCommand | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §§2/4/5/6 — TranscriptCommand abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Only four scroll commands exist (Cline parity):
|
|
5
|
+
* messages_half_page_up / messages_half_page_down /
|
|
6
|
+
* messages_first / messages_last
|
|
7
|
+
*
|
|
8
|
+
* Flow (§6): Keyboard → getTranscriptCommand() → TranscriptScrollHandle →
|
|
9
|
+
* ChatMessageList. Keys are matched in ONE place (the §5 binding table);
|
|
10
|
+
* the App routes everything else (history, autocomplete, approvals).
|
|
11
|
+
*
|
|
12
|
+
* Note on the stack: the doc's `<scrollbox stickyScroll>` is OpenTUI-only
|
|
13
|
+
* and this app renders with Ink, which has no scrollbox primitive — so the
|
|
14
|
+
* handle drives the measured anchor viewport (tui/scroll-model.ts) instead.
|
|
15
|
+
* The command vocabulary and all §21 behaviors are identical.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* scroll.md §5 binding table (pure — unit-tested):
|
|
19
|
+
* PageUp / Ctrl+U → up · PageDown / Ctrl+D → down ·
|
|
20
|
+
* Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
|
|
21
|
+
*/
|
|
22
|
+
export function getTranscriptCommand(input, key) {
|
|
23
|
+
if (key.pageUp || (key.ctrl && input === 'u'))
|
|
24
|
+
return 'messages_half_page_up';
|
|
25
|
+
if (key.pageDown || (key.ctrl && input === 'd'))
|
|
26
|
+
return 'messages_half_page_down';
|
|
27
|
+
if (key.home && key.ctrl)
|
|
28
|
+
return 'messages_first';
|
|
29
|
+
if (key.end && key.ctrl)
|
|
30
|
+
return 'messages_last';
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
package/package.json
CHANGED