Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Added

- Added `/transcript` to browse long TUI sessions without depending on terminal
scrollback, with line, page, and first/last navigation.

## 0.1.11 - 2026-08-18

### Highlights
Expand Down
94 changes: 92 additions & 2 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1604,8 +1604,8 @@ describe('Maka Pi TUI runner', () => {
'the head of a tall reply must still be written out',
);

// No in-app pager: the removed scroll indicator and its PgUp/PgDn hint never
// appear. History is scrolled through the terminal's own scrollback instead.
// The live surface stays unpaged until the user explicitly opens the
// transcript viewer. Its navigation chrome must not consume normal rows.
assert.doesNotMatch(cumulative, /PgUp|PgDn|\d+ more/);

// The visible screen follows the tail: the last reply line and the status
Expand Down Expand Up @@ -1635,6 +1635,65 @@ describe('Maka Pi TUI runner', () => {
]);
});

test('browses a long transcript without depending on terminal scrollback', async () => {
const terminal = new FakeTerminal();
const driver = new LongTranscriptDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'deepseek-v4-flash',
connectionSlug: 'deepseek',
permissionMode: 'ask',
terminal,
});

terminal.input('fill');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('filler line 40'));

terminal.input('/transcript');
terminal.input('\r');
await waitFor(
() => plainTerminalOutput(terminal.screenOutput()).includes('TRANSCRIPT'),
'the transcript viewer to open',
);
let screen = plainTerminalOutput(terminal.screenOutput());
assert.match(screen, /PgUp\/PgDn page/);
assert.match(screen, /filler line 40/);
assert.doesNotMatch(screen, /filler line 1\s/);

terminal.input('\x1b[H');
await waitFor(
() =>
plainTerminalOutput(terminal.screenOutput())
.split(/\r?\n/)
.some((line) => line.trim() === 'filler line 1'),
'Home to reveal the transcript head',
);
screen = plainTerminalOutput(terminal.screenOutput());
assert.match(screen, /> fill/);
assert.doesNotMatch(screen, /filler line 40/);

terminal.input('q');
await waitFor(
() => !plainTerminalOutput(terminal.screenOutput()).includes('TRANSCRIPT'),
'q to close the transcript viewer',
);
assert.match(
plainTerminalOutput(terminal.screenOutput()),
/Maka · Auto · deepseek-v4-flash · deepseek · \/repo/,
);

exitMaka(terminal);
await Promise.race([
run,
delay(CLOSE_BUDGET_MS).then(() => {
throw new Error('TUI did not close during test cleanup');
}),
]);
});

test('clears an unsent draft on Ctrl-C without closing Maka', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
Expand Down Expand Up @@ -1782,6 +1841,37 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('opens /transcript during a running turn instead of steering it', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the work');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/transcript');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('TRANSCRIPT'));
assert.deepEqual(driver.steered, []);

terminal.input('q');
terminal.input('\x1b');
terminal.input('\x1b');
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('quit during a running turn closes the TUI instead of steering it', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand Down
240 changes: 240 additions & 0 deletions packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { TranscriptViewerOverlay } from '../pi-tui-transcript-viewer.js';
import { MakaTranscriptComponent } from '../pi-tui-layout.js';
import { createMakaPiTranscriptState } from '../pi-transcript.js';
import { stripAnsi } from '../tui-ansi.js';

describe('TranscriptViewerOverlay', () => {
test('opens at the tail and supports line, page, and boundary navigation', () => {
const document = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
let changes = 0;
let closed = 0;
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => document,
viewportRows: () => 6,
onChange: () => {
changes += 1;
},
onClose: () => {
closed += 1;
},
});

assert.deepEqual(plain(viewer.render(40)).slice(1, -1).map(trim), [
'line 9',
'line 10',
'line 11',
'line 12',
]);

viewer.handleInput('\x1b[A');
assert.deepEqual(plain(viewer.render(40)).slice(1, -1).map(trim), [
'line 8',
'line 9',
'line 10',
'line 11',
]);

viewer.handleInput('\x1b[5~');
assert.deepEqual(plain(viewer.render(40)).slice(1, -1).map(trim), [
'line 4',
'line 5',
'line 6',
'line 7',
]);

viewer.handleInput('\x1b[H');
assert.deepEqual(plain(viewer.render(40)).slice(1, -1).map(trim), [
'line 1',
'line 2',
'line 3',
'line 4',
]);

viewer.handleInput('\x1b[F');
assert.deepEqual(plain(viewer.render(40)).slice(1, -1).map(trim), [
'line 9',
'line 10',
'line 11',
'line 12',
]);
assert.equal(changes, 4);
assert.equal(closed, 0);
});

test('follows appended output only while positioned at the end', () => {
const document = Array.from({ length: 6 }, (_, index) => `line ${index + 1}`);
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => document,
viewportRows: () => 5,
onChange: () => {},
onClose: () => {},
});

assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 4',
'line 5',
'line 6',
]);
document.push('line 7');
assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 5',
'line 6',
'line 7',
]);

viewer.handleInput('\x1b[A');
document.push('line 8');
assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 4',
'line 5',
'line 6',
]);

viewer.handleInput('\x1b[6~');
document.push('line 9');
assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 7',
'line 8',
'line 9',
]);
});

test('keeps following after a no-op upward scroll on a short transcript', () => {
const document = ['line 1', 'line 2'];
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => document,
viewportRows: () => 6,
onChange: () => {},
onClose: () => {},
});

viewer.render(30);
viewer.handleInput('\x1b[A');
for (let index = 3; index <= 10; index += 1) document.push(`line ${index}`);

assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 7',
'line 8',
'line 9',
'line 10',
]);
});

test('resumes following after a resize clamps a detached viewport to the tail', () => {
const document = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
let viewportRows = 6;
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => document,
viewportRows: () => viewportRows,
onChange: () => {},
onClose: () => {},
});

viewer.render(30);
viewer.handleInput('\x1b[A');
viewportRows = 7;
viewer.render(30);
document.push('line 13');

assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
'line 9',
'line 10',
'line 11',
'line 12',
'line 13',
]);
});

test('closes with q or Escape', () => {
let closed = 0;
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => [],
viewportRows: () => 4,
onChange: () => {},
onClose: () => {
closed += 1;
},
});

viewer.handleInput('q');
viewer.handleInput('\x1b');
assert.equal(closed, 2);
});

test('prioritizes content and keeps a valid range in tiny viewports', () => {
const document = ['line 1', 'line 2', 'line 3'];
let viewportRows = 2;
const viewer = new TranscriptViewerOverlay({
renderTranscript: () => document,
viewportRows: () => viewportRows,
onChange: () => {},
onClose: () => {},
});

assert.deepEqual(plain(viewer.render(30)).map(trim), ['TRANSCRIPT 3-3 of 3', 'line 3']);

viewportRows = 1;
assert.deepEqual(plain(viewer.render(30)).map(trim), ['TRANSCRIPT 0-0 of 3']);

viewportRows = 4;
const resized = plain(viewer.render(30)).map(trim);
assert.deepEqual(resized.slice(0, 3), ['TRANSCRIPT 2-3 of 3', 'line 2', 'line 3']);
assert.match(resized[3] ?? '', /PgUp\/PgDn page/);
});

test('renders through a detached geometry projection', () => {
const state = createMakaPiTranscriptState();
const entry = { kind: 'user' as const, text: 'oldest prompt' };
const entryFirstLine = new Map([[entry, 17]]);
state.entries.push(entry);
state.renderGeometry = { entryFirstLine, viewportTop: 16 };
const transcript = new MakaTranscriptComponent(state, () => ({
title: 'Maka',
cwd: '/repo',
model: 'model',
connectionSlug: 'connection',
permissionMode: 'ask',
}));

const renderDocument = transcript.createDocumentRenderer();
assert.ok(plain(renderDocument(40)).some((line) => line.includes('oldest prompt')));
assert.equal(state.renderGeometry.viewportTop, 16);
assert.strictEqual(state.renderGeometry.entryFirstLine, entryFirstLine);
});

test('does not replace the frozen live-scrollback render cache', () => {
const state = createMakaPiTranscriptState();
const entry = { kind: 'assistant' as const, messageId: 'message-1', text: 'settled text' };
state.entries.push(entry);
const transcript = new MakaTranscriptComponent(state, () => ({
title: 'Maka',
cwd: '/repo',
model: 'model',
connectionSlug: 'connection',
permissionMode: 'ask',
}));

assert.ok(plain(transcript.render(40)).some((line) => line.includes('settled text')));
state.renderGeometry.viewportTop = 100;
entry.text = 'background update';
const renderDocument = transcript.createDocumentRenderer();
assert.ok(plain(renderDocument(40)).some((line) => line.includes('background update')));

const liveLines = plain(transcript.render(40));
assert.ok(liveLines.some((line) => line.includes('settled text')));
assert.equal(
liveLines.some((line) => line.includes('background update')),
false,
);
});
});

function plain(lines: readonly string[]): string[] {
return lines.map(stripAnsi);
}

function trim(line: string): string {
return line.trimEnd();
}
Loading
Loading