diff --git a/CHANGELOG.md b/CHANGELOG.md index 85796a92cc..1182d39c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index efd24ffd38..439a4b5800 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -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 @@ -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(); @@ -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(); diff --git a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts new file mode 100644 index 0000000000..72fe39c7fb --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -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(); +} diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index 8bf4303b50..c390a48728 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -8,6 +8,7 @@ import { renderMakaPiPendingQueue, renderMakaPiStatusLine, renderMakaPiTranscript, + type MakaPiTranscriptEntry, type MakaPiTranscriptMetadata, type MakaPiTranscriptState, } from './pi-transcript.js'; @@ -37,6 +38,36 @@ 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. + */ + createDocumentRenderer(): (width: number) => string[] { + // The detached keys and their rendered-line cache live only as long as one + // viewer overlay. Closing it releases the complete duplicate projection. + const entryClones = new WeakMap(); + const documentEntry = (entry: MakaPiTranscriptEntry): MakaPiTranscriptEntry => { + const cached = entryClones.get(entry); + if (cached) { + Object.assign(cached, entry); + return cached; + } + const clone = { ...entry } as MakaPiTranscriptEntry; + entryClones.set(entry, clone); + return clone; + }; + return (width) => + renderMakaPiTranscript( + { + ...this.state, + entries: this.state.entries.map(documentEntry), + renderGeometry: { entryFirstLine: undefined, viewportTop: 0 }, + }, + this.metadata(), + width, + ); + } } export class MakaStatusLineComponent implements Component { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index eed52e6441..4f28019911 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -84,6 +84,7 @@ import { import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; +import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js'; import { createShellRunHydrationController } from './shell-run-hydration.js'; import { @@ -992,6 +993,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { beginGracefulClose(); return; } + if (prompt.trim().split(/\s+/, 1)[0] === '/transcript') { + editor.addToHistory(prompt); + handleSlashCommand(prompt, 0); + return; + } const swarmCommand = parseSwarmCommand(prompt); if (swarmCommand) { editor.addToHistory(prompt); @@ -2065,6 +2071,22 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + const showTranscriptViewer = (): void => { + let overlay: OverlayHandle | undefined; + const renderTranscript = transcript.createDocumentRenderer(); + const viewer = new TranscriptViewerOverlay({ + renderTranscript, + viewportRows: () => terminal.rows, + onClose: () => overlay?.hide(), + onChange: () => tui.requestRender(), + }); + overlay = tui.showOverlay(viewer, { + anchor: 'top-left', + width: '100%', + maxHeight: '100%', + }); + }; + const showModelList = () => { const choices = modelChoices; const hasConversationHistory = state.entries.some( @@ -2545,6 +2567,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void runControl(() => setThinkingLevel(level)); }, }, + transcript: { + description: primaryGuidance.commands.transcript, + run: (parts: string[]) => { + if (parts.length !== 1) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /transcript', + }); + requestRender(); + return; + } + showTranscriptViewer(); + }, + }, permissions: { description: primaryGuidance.commands.permissions, run: (parts: string[]) => { diff --git a/packages/cli/src/pi-tui-transcript-viewer.ts b/packages/cli/src/pi-tui-transcript-viewer.ts new file mode 100644 index 0000000000..80963a3f44 --- /dev/null +++ b/packages/cli/src/pi-tui-transcript-viewer.ts @@ -0,0 +1,130 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, +} from '@earendil-works/pi-tui'; +import { ansi } from './tui-ansi.js'; + +const VIEWER_CHROME_ROWS = 2; + +export interface TranscriptViewerInput { + /** Produces the current read-only CLI transcript projection at this width. */ + renderTranscript(width: number): readonly string[]; + viewportRows(): number; + onClose(): void; + onChange(): void; +} + +/** + * Full-screen, read-only navigation over the CLI transcript projection. + * + * The normal editor keeps ownership of its navigation keys. This component only + * sees them while its capturing overlay is focused, so opening the viewer does + * not create a second set of global editor bindings or a second history source. + */ +export class TranscriptViewerOverlay implements Component { + private top = 0; + private documentRows = 0; + private bodyRows = 0; + private followsEnd = true; + + constructor(private readonly input: TranscriptViewerInput) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, 'q')) { + this.input.onClose(); + return; + } + if (matchesKey(data, Key.up)) { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down)) { + this.scrollBy(1); + return; + } + if (matchesKey(data, Key.pageUp)) { + this.scrollBy(-Math.max(1, this.bodyRows)); + return; + } + if (matchesKey(data, Key.pageDown)) { + this.scrollBy(Math.max(1, this.bodyRows)); + return; + } + if (matchesKey(data, Key.home)) { + this.followsEnd = false; + this.top = 0; + this.input.onChange(); + return; + } + if (matchesKey(data, Key.end)) { + this.followsEnd = true; + this.top = this.maxTop(); + this.input.onChange(); + } + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const viewportRows = Math.max(1, Math.floor(this.input.viewportRows())); + // A two-row terminal is still more useful with one document row than with + // navigation chrome only. The footer appears once header + body + footer fit. + const showFooter = viewportRows > 2; + this.bodyRows = Math.max(0, viewportRows - (showFooter ? VIEWER_CHROME_ROWS : 1)); + + const document = [...this.input.renderTranscript(safeWidth)]; + this.documentRows = document.length; + const maxTop = this.maxTop(); + this.top = this.followsEnd ? maxTop : clamp(this.top, 0, maxTop); + this.followsEnd = this.top === maxTop; + + const visible = document.slice(this.top, this.top + this.bodyRows); + const start = visible.length === 0 ? 0 : this.top + 1; + const end = visible.length === 0 ? 0 : this.top + visible.length; + const header = padLine( + `${ansi.bold('TRANSCRIPT')} ${ansi.dim(`${start}-${end} of ${document.length}`)}`, + safeWidth, + ); + const body = [ + ...visible.map((line) => padLine(line, safeWidth)), + ...Array.from({ length: Math.max(0, this.bodyRows - visible.length) }, () => + ' '.repeat(safeWidth), + ), + ]; + if (!showFooter) return [header, ...body]; + + const footer = padLine( + ansi.dim('↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close'), + safeWidth, + ); + return [header, ...body, footer]; + } + + private scrollBy(delta: number): void { + const maxTop = this.maxTop(); + this.top = clamp(this.top + delta, 0, maxTop); + // Follow the tail whenever the clamped position is the end, including the + // no-op case where a short transcript cannot move at all: a stray Up key + // must not pin the viewer at the head once the transcript grows. + this.followsEnd = this.top === maxTop; + this.input.onChange(); + } + + private maxTop(): number { + return Math.max(0, this.documentRows - this.bodyRows); + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function padLine(text: string, width: number): string { + const safeWidth = Math.max(1, width); + const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text; + return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; +} diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index f9bc1aea66..7ffb91fdaf 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -47,6 +47,7 @@ const TUI_PRIMARY_GUIDANCE = { skill: '调用 Skill(也可直接输入 /skill:)', swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', thinking: '设置思考级别', + transcript: '在 Maka 内浏览完整对话记录', }, help: { commandsHeading: '命令', @@ -92,6 +93,7 @@ const TUI_PRIMARY_GUIDANCE = { skill: 'Invoke a skill (or type /skill: inline)', swarm: 'Show, enable, disable, or run one Swarm turn', thinking: 'Set thinking level', + transcript: 'Browse the full transcript inside Maka', }, help: { commandsHeading: 'Commands', diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 3c3a443904..f6854e0c52 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -28,6 +28,7 @@ export const SLASH_COMMAND_CATALOG = [ { id: 'skill', session: 'required', surfaces: ['tui'] }, { id: 'swarm', session: 'none', surfaces: ['desktop', 'tui'] }, { id: 'thinking', session: 'required', surfaces: ['tui'] }, + { id: 'transcript', session: 'required', surfaces: ['tui'] }, ] as const satisfies readonly SlashCommandSpec[]; export type SlashCommandId = (typeof SLASH_COMMAND_CATALOG)[number]['id'];