klyro 0.1.56 → 0.1.58
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 +23 -7
- package/dist/tui/app.test.js +91 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -27,7 +27,7 @@ function Header({ cwd, model, version, width }) {
|
|
|
27
27
|
}
|
|
28
28
|
}, [cwd]);
|
|
29
29
|
const showLinks = width >= 120;
|
|
30
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
|
|
31
31
|
}
|
|
32
32
|
function verbForTool(name) {
|
|
33
33
|
if (name === 'read_file')
|
|
@@ -153,6 +153,7 @@ function useChatScroll(opts) {
|
|
|
153
153
|
pageDown: () => dispatch({ type: 'BY_HALF_PAGE', dir: 1 }),
|
|
154
154
|
jumpTop: () => dispatch({ type: 'TO_TOP' }),
|
|
155
155
|
jumpBottom: () => dispatch({ type: 'TO_BOTTOM' }),
|
|
156
|
+
reset: () => dispatch({ type: 'TO_BOTTOM' }),
|
|
156
157
|
}), [dispatch]);
|
|
157
158
|
const resolved = resolveTopRow(state, ctx);
|
|
158
159
|
const maxTop = maxTopFor(ctx);
|
|
@@ -216,6 +217,7 @@ export function App(props) {
|
|
|
216
217
|
bottom: () => { },
|
|
217
218
|
halfPage: (_dir) => { },
|
|
218
219
|
top: () => { },
|
|
220
|
+
reset: () => { },
|
|
219
221
|
});
|
|
220
222
|
const width = stdout?.columns ?? 100;
|
|
221
223
|
const height = stdout?.rows ?? 30;
|
|
@@ -357,6 +359,7 @@ export function App(props) {
|
|
|
357
359
|
}
|
|
358
360
|
},
|
|
359
361
|
bottom: () => commands.jumpBottom(),
|
|
362
|
+
reset: () => commands.reset(),
|
|
360
363
|
halfPage: (dir) => {
|
|
361
364
|
if (dir < 0)
|
|
362
365
|
commands.pageUp();
|
|
@@ -416,10 +419,15 @@ export function App(props) {
|
|
|
416
419
|
thinkingIdRef.current = null;
|
|
417
420
|
setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
|
|
418
421
|
}, []);
|
|
419
|
-
useEffect(() => {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
if (status.status !== 'running') {
|
|
424
|
+
streamingIdRef.current = null;
|
|
425
|
+
thinkingIdRef.current = null;
|
|
426
|
+
// Runs that end without final_text (abort/cancel/error) must not leave
|
|
427
|
+
// stale thinking blocks behind — only the response may remain.
|
|
428
|
+
setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
|
|
429
|
+
}
|
|
430
|
+
}, [status.status]);
|
|
423
431
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
424
432
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
425
433
|
// Tool results patch the running start-item IN PLACE (no second item, so a
|
|
@@ -436,7 +444,15 @@ export function App(props) {
|
|
|
436
444
|
return copy;
|
|
437
445
|
});
|
|
438
446
|
}, []);
|
|
439
|
-
const clearTranscript = useCallback(() => {
|
|
447
|
+
const clearTranscript = useCallback(() => {
|
|
448
|
+
streamingIdRef.current = null;
|
|
449
|
+
thinkingIdRef.current = null;
|
|
450
|
+
setTranscript([]);
|
|
451
|
+
setPlan([]);
|
|
452
|
+
// Fresh content → fresh scroll (a pruned anchor would otherwise stick to
|
|
453
|
+
// the bottom with a stale newSinceUnstick badge count).
|
|
454
|
+
scrollCmdsRef.current.reset();
|
|
455
|
+
}, []);
|
|
440
456
|
// Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
|
|
441
457
|
// Stored in refs so the callbacks stay stable while acting on latest state.
|
|
442
458
|
const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
|
|
@@ -751,5 +767,5 @@ export function App(props) {
|
|
|
751
767
|
if (it.kind === 'diff')
|
|
752
768
|
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));
|
|
753
769
|
return null;
|
|
754
|
-
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _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,
|
|
770
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _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, flexShrink: 0, 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, flexShrink: 0, 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, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, 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", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, 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' ? ' ●' : ''] })] })] }));
|
|
755
771
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -464,6 +464,97 @@ describe('App', () => {
|
|
|
464
464
|
hooks.clearThinking();
|
|
465
465
|
await waitForAbsent(lastFrame, /weighing two approaches/);
|
|
466
466
|
});
|
|
467
|
+
it('empty state: composer pinned to bottom, header first (TEST 1)', async () => {
|
|
468
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true }));
|
|
469
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
470
|
+
const rows = (lastFrame() ?? '').split('\n');
|
|
471
|
+
expect(rows[0]).toMatch(/KLYRO/);
|
|
472
|
+
const nonEmpty = rows.map((r, i) => ({ r, i })).filter((x) => x.r.trim().length > 0);
|
|
473
|
+
const last = nonEmpty[nonEmpty.length - 1];
|
|
474
|
+
expect(last.r).toMatch(/enter to send|for history|to attach/);
|
|
475
|
+
const placeholder = rows.findIndex((r) => r.includes('Message Klyro'));
|
|
476
|
+
expect(placeholder).toBeGreaterThanOrEqual(0);
|
|
477
|
+
expect(placeholder).toBeLessThan(last.i);
|
|
478
|
+
});
|
|
479
|
+
it('multiline input grows upward, status stays last (TEST 5)', async () => {
|
|
480
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true }));
|
|
481
|
+
stdin.write('build authentication');
|
|
482
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
483
|
+
stdin.write('\x1b[13;2u');
|
|
484
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
485
|
+
stdin.write('add tests');
|
|
486
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
487
|
+
const rows = (lastFrame() ?? '').split('\n');
|
|
488
|
+
expect(rows.join('\n')).toContain('build authentication');
|
|
489
|
+
expect(rows.join('\n')).toContain('add tests');
|
|
490
|
+
const nonEmpty = rows.map((r, i) => ({ r, i })).filter((x) => x.r.trim().length > 0);
|
|
491
|
+
const last = nonEmpty[nonEmpty.length - 1];
|
|
492
|
+
expect(last.r).toMatch(/enter to send|for history|to attach/);
|
|
493
|
+
expect(rows.length).toBeLessThanOrEqual(32);
|
|
494
|
+
});
|
|
495
|
+
it('tool events aggregate, no raw internals leak (TEST 13)', async () => {
|
|
496
|
+
let hooks = null;
|
|
497
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, onMounted: (h) => {
|
|
498
|
+
hooks = { append: h.append, updateTool: h.updateTool };
|
|
499
|
+
} }));
|
|
500
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
501
|
+
const tools = [
|
|
502
|
+
['read_file', 'c1', '{"path":"a.ts"}'],
|
|
503
|
+
['read_file', 'c2', '{"path":"b.ts"}'],
|
|
504
|
+
['read_file', 'c3', '{"path":"c.ts"}'],
|
|
505
|
+
['shell_exec', 'c4', '{"command":"npm test"}'],
|
|
506
|
+
['shell_exec', 'c5', '{"command":"npm run build"}'],
|
|
507
|
+
];
|
|
508
|
+
for (const [name, id, args] of tools) {
|
|
509
|
+
hooks.append({ id: `t-${id}`, kind: 'tool', name, id_call: id, args, status: 'running' });
|
|
510
|
+
}
|
|
511
|
+
await waitForMatch(lastFrame, /Read 3 files/);
|
|
512
|
+
for (const [, id] of tools) {
|
|
513
|
+
hooks.updateTool(id, { result: 'ok', isError: false, latencyMs: 5, status: 'done' });
|
|
514
|
+
}
|
|
515
|
+
const frame = await waitForMatch(lastFrame, /Ran 2 commands/);
|
|
516
|
+
expect(frame).toContain('Read 3 files');
|
|
517
|
+
expect(frame).not.toContain('tool_call');
|
|
518
|
+
expect(frame).not.toContain('{"path"');
|
|
519
|
+
expect(frame).not.toContain('queued:');
|
|
520
|
+
});
|
|
521
|
+
it('aborted run leaves no thinking behind (only response may remain)', async () => {
|
|
522
|
+
let hooks = null;
|
|
523
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialStatus: { status: 'running' }, onMounted: (h) => {
|
|
524
|
+
hooks = { appendThinkingDelta: h.appendThinkingDelta, updateStatus: h.updateStatus };
|
|
525
|
+
} }));
|
|
526
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
527
|
+
hooks.appendThinkingDelta('half-formed thought...');
|
|
528
|
+
await waitForMatch(lastFrame, /half-formed thought/);
|
|
529
|
+
// Abort (no final_text): thinking must be cleaned up, not linger.
|
|
530
|
+
hooks.updateStatus({ status: 'aborted' });
|
|
531
|
+
await waitForAbsent(lastFrame, /half-formed thought/);
|
|
532
|
+
});
|
|
533
|
+
it('clearTranscript resets scroll state (no stale badge count)', async () => {
|
|
534
|
+
let hooks = null;
|
|
535
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
|
|
536
|
+
hooks = { append: h.append, clearTranscript: h.clearTranscript };
|
|
537
|
+
} }));
|
|
538
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
539
|
+
stdin.write(KEY_HOME);
|
|
540
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
541
|
+
hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
|
|
542
|
+
// Badge counts lines grown while pinned.
|
|
543
|
+
await waitForMatch(lastFrame, /↓ \d+ new/);
|
|
544
|
+
hooks.clearTranscript();
|
|
545
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
546
|
+
// Fresh session: seed 25 more, pin, grow by one 2-line item → badge is exactly 2.
|
|
547
|
+
for (let i = 0; i < 25; i++) {
|
|
548
|
+
hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
|
|
549
|
+
}
|
|
550
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
551
|
+
stdin.write(KEY_HOME);
|
|
552
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
553
|
+
hooks.append({ id: 'n-late', kind: 'text', text: 'NEWLATE-tag', role: 'assistant' });
|
|
554
|
+
// Assistant block = header + text + margin = 3 fresh lines, no stale count.
|
|
555
|
+
const badge = await waitForMatch(lastFrame, /↓ \d+ new/);
|
|
556
|
+
expect(badge).toMatch(/↓ 3 new/);
|
|
557
|
+
});
|
|
467
558
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
468
559
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
469
560
|
await new Promise((r) => setTimeout(r, 50));
|
package/package.json
CHANGED