Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
63 changes: 61 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
215 changes: 215 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,215 @@
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('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',
}));

assert.ok(plain(transcript.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';
assert.ok(
plain(transcript.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();
}
34 changes: 34 additions & 0 deletions packages/cli/src/pi-tui-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
renderMakaPiPendingQueue,
renderMakaPiStatusLine,
renderMakaPiTranscript,
type MakaPiTranscriptEntry,
type MakaPiTranscriptMetadata,
type MakaPiTranscriptState,
} from './pi-transcript.js';
Expand All @@ -27,6 +28,12 @@ export function fitPendingQueueLines(lines: readonly string[], maxRows: number):
}

export class MakaTranscriptComponent implements Component {
/** Separate WeakMap keys keep viewer renders out of the live scrollback cache. */
private readonly documentEntryClones = new WeakMap<
Comment thread
me2seeks marked this conversation as resolved.
Outdated
MakaPiTranscriptEntry,
MakaPiTranscriptEntry
>();

constructor(
private readonly state: MakaPiTranscriptState,
private readonly metadata: () => MakaPiTranscriptMetadata,
Expand All @@ -37,6 +44,33 @@ export class MakaTranscriptComponent implements Component {
render(width: number): string[] {
return renderMakaPiTranscript(this.state, this.metadata(), width);
}

/**
* Render the complete current projection without changing the geometry used
* by the live terminal-scrollback reconciliation path.
*/
renderDocument(width: number): string[] {
return renderMakaPiTranscript(
{
...this.state,
entries: this.state.entries.map((entry) => this.documentEntry(entry)),
renderGeometry: { entryFirstLine: undefined, viewportTop: 0 },
},
this.metadata(),
width,
);
}

private documentEntry(entry: MakaPiTranscriptEntry): MakaPiTranscriptEntry {
const cached = this.documentEntryClones.get(entry);
if (cached) {
Object.assign(cached, entry);
return cached;
}
const clone = { ...entry } as MakaPiTranscriptEntry;
this.documentEntryClones.set(entry, clone);
return clone;
}
}

export class MakaStatusLineComponent implements Component {
Expand Down
Loading
Loading