From c9146a27e73f1e60cfd27a6670ae4c28358e9be1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 15:09:50 +0800 Subject: [PATCH 1/5] feat(cli): add transcript viewer for long sessions Add a full-screen, read-only /transcript overlay with line, page, and boundary navigation. Keep the existing live transcript and rewind interactions unchanged while rendering the viewer through a detached geometry projection. --- CHANGELOG.md | 5 + .../cli/src/__tests__/pi-tui-runner.test.ts | 63 +++++++- .../pi-tui-transcript-viewer.test.ts | 146 ++++++++++++++++++ packages/cli/src/pi-tui-layout.ts | 15 ++ packages/cli/src/pi-tui-runner.ts | 31 ++++ packages/cli/src/pi-tui-transcript-viewer.ts | 124 +++++++++++++++ packages/cli/src/tui-primary-guidance.ts | 2 + packages/core/src/slash-command-catalog.ts | 1 + 8 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts create mode 100644 packages/cli/src/pi-tui-transcript-viewer.ts 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..897047578c 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(); 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..19b9fbcad9 --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -0,0 +1,146 @@ +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('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('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); + }); +}); + +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..49a16cdec8 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -37,6 +37,21 @@ 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, + 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..075214f5c7 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 { @@ -2065,6 +2066,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + const showTranscriptViewer = (): void => { + let overlay: OverlayHandle | undefined; + const viewer = new TranscriptViewerOverlay({ + renderTranscript: (width) => transcript.renderDocument(width), + 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 +2561,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..82c6552f58 --- /dev/null +++ b/packages/cli/src/pi-tui-transcript-viewer.ts @@ -0,0 +1,124 @@ +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())); + const showFooter = viewportRows > 1; + 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); + + const visible = document.slice(this.top, this.top + this.bodyRows); + const start = document.length === 0 ? 0 : this.top + 1; + const end = document.length === 0 ? 0 : Math.min(document.length, this.top + this.bodyRows); + const header = padLine( + `${ansi.bold('TRANSCRIPT')} ${ansi.dim(`${start}-${end} of ${document.length}`)}`, + safeWidth, + ); + if (!showFooter) return [header]; + + const body = [ + ...visible.map((line) => padLine(line, safeWidth)), + ...Array.from({ length: Math.max(0, this.bodyRows - visible.length) }, () => + ' '.repeat(safeWidth), + ), + ]; + 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); + this.followsEnd = delta > 0 && 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']; From b157fbba3878b0a309ed63a9e4e0b4f6e329578b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 15:13:51 +0800 Subject: [PATCH 2/5] fix(cli): isolate transcript viewer render cache Give the viewer stable entry clones so its full-document renders cannot replace memoized lines that the live surface has frozen in terminal scrollback. Cover the background-update path with a regression test. --- .../pi-tui-transcript-viewer.test.ts | 24 +++++++++++++++++++ packages/cli/src/pi-tui-layout.ts | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts index 19b9fbcad9..1caf85367b 100644 --- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -135,6 +135,30 @@ describe('TranscriptViewerOverlay', () => { 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[] { diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index 49a16cdec8..d868a49b17 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'; @@ -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< + MakaPiTranscriptEntry, + MakaPiTranscriptEntry + >(); + constructor( private readonly state: MakaPiTranscriptState, private readonly metadata: () => MakaPiTranscriptMetadata, @@ -46,12 +53,24 @@ export class MakaTranscriptComponent implements Component { 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 { From d735021f34d0a889dab940a164d67864a4d49f01 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 15:18:33 +0800 Subject: [PATCH 3/5] fix(cli): keep transcript readable in tiny terminals Prefer one transcript row over the navigation footer when only two terminal rows are available, and keep the displayed range valid when no body row fits. --- .../pi-tui-transcript-viewer.test.ts | 26 ++++++++++++++++++- packages/cli/src/pi-tui-transcript-viewer.ts | 12 +++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts index 1caf85367b..d82c350fa0 100644 --- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -117,6 +117,27 @@ describe('TranscriptViewerOverlay', () => { 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' }; @@ -157,7 +178,10 @@ describe('TranscriptViewerOverlay', () => { 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); + assert.equal( + liveLines.some((line) => line.includes('background update')), + false, + ); }); }); diff --git a/packages/cli/src/pi-tui-transcript-viewer.ts b/packages/cli/src/pi-tui-transcript-viewer.ts index 82c6552f58..90fd07dfaa 100644 --- a/packages/cli/src/pi-tui-transcript-viewer.ts +++ b/packages/cli/src/pi-tui-transcript-viewer.ts @@ -71,7 +71,9 @@ export class TranscriptViewerOverlay implements Component { render(width: number): string[] { const safeWidth = Math.max(1, width); const viewportRows = Math.max(1, Math.floor(this.input.viewportRows())); - const showFooter = viewportRows > 1; + // 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)]; @@ -80,20 +82,20 @@ export class TranscriptViewerOverlay implements Component { this.top = this.followsEnd ? maxTop : clamp(this.top, 0, maxTop); const visible = document.slice(this.top, this.top + this.bodyRows); - const start = document.length === 0 ? 0 : this.top + 1; - const end = document.length === 0 ? 0 : Math.min(document.length, 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, ); - if (!showFooter) return [header]; - 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, From 33fd635d1bd850902aa51ba70e21b83bd894aadf Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 02:05:08 +0800 Subject: [PATCH 4/5] fix(cli): keep transcript follow after a no-op upward scroll scrollBy derived followsEnd from the scroll direction, so pressing Up on a transcript that already fit the viewport pinned the viewer at the head once it grew scrollable. Derive follow from the clamped position instead: the viewer keeps following whenever it ends at the tail, including the no-op case. --- .../pi-tui-transcript-viewer.test.ts | 21 +++++++++++++++++++ packages/cli/src/pi-tui-transcript-viewer.ts | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts index d82c350fa0..1295214f0e 100644 --- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -101,6 +101,27 @@ describe('TranscriptViewerOverlay', () => { ]); }); + 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({ diff --git a/packages/cli/src/pi-tui-transcript-viewer.ts b/packages/cli/src/pi-tui-transcript-viewer.ts index 90fd07dfaa..2c8b68503d 100644 --- a/packages/cli/src/pi-tui-transcript-viewer.ts +++ b/packages/cli/src/pi-tui-transcript-viewer.ts @@ -106,7 +106,10 @@ export class TranscriptViewerOverlay implements Component { private scrollBy(delta: number): void { const maxTop = this.maxTop(); this.top = clamp(this.top + delta, 0, maxTop); - this.followsEnd = delta > 0 && this.top === 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(); } From 7c952e35d129ee6b5275d80707b96f3f6ac0e90d Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 00:41:43 +0800 Subject: [PATCH 5/5] fix(cli): keep transcript viewer live and bounded --- .../cli/src/__tests__/pi-tui-runner.test.ts | 31 +++++++++++ .../pi-tui-transcript-viewer.test.ts | 33 ++++++++++-- packages/cli/src/pi-tui-layout.ts | 51 +++++++++---------- packages/cli/src/pi-tui-runner.ts | 8 ++- packages/cli/src/pi-tui-transcript-viewer.ts | 1 + 5 files changed, 92 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 897047578c..439a4b5800 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1841,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 index 1295214f0e..72fe39c7fb 100644 --- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts +++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts @@ -122,6 +122,31 @@ describe('TranscriptViewerOverlay', () => { ]); }); + 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({ @@ -173,7 +198,8 @@ describe('TranscriptViewerOverlay', () => { permissionMode: 'ask', })); - assert.ok(plain(transcript.renderDocument(40)).some((line) => line.includes('oldest prompt'))); + 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); }); @@ -193,9 +219,8 @@ describe('TranscriptViewerOverlay', () => { 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 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'))); diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index d868a49b17..c390a48728 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -28,12 +28,6 @@ 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< - MakaPiTranscriptEntry, - MakaPiTranscriptEntry - >(); - constructor( private readonly state: MakaPiTranscriptState, private readonly metadata: () => MakaPiTranscriptMetadata, @@ -49,27 +43,30 @@ export class MakaTranscriptComponent implements Component { * 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; + 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, + ); } } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 075214f5c7..4f28019911 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -993,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); @@ -2068,8 +2073,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const showTranscriptViewer = (): void => { let overlay: OverlayHandle | undefined; + const renderTranscript = transcript.createDocumentRenderer(); const viewer = new TranscriptViewerOverlay({ - renderTranscript: (width) => transcript.renderDocument(width), + renderTranscript, viewportRows: () => terminal.rows, onClose: () => overlay?.hide(), onChange: () => tui.requestRender(), diff --git a/packages/cli/src/pi-tui-transcript-viewer.ts b/packages/cli/src/pi-tui-transcript-viewer.ts index 2c8b68503d..80963a3f44 100644 --- a/packages/cli/src/pi-tui-transcript-viewer.ts +++ b/packages/cli/src/pi-tui-transcript-viewer.ts @@ -80,6 +80,7 @@ export class TranscriptViewerOverlay implements Component { 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;