You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/08 12:57:58 UTC
(maka) branch main updated: feat(cli): unify detailed transcript navigation (#5029)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 53032c4b0c feat(cli): unify detailed transcript navigation (#5029)
53032c4b0c is described below
commit 53032c4b0c2a7729eb38b09d3072f92f41af8f0d
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Tue Sep 8 20:57:52 2026 +0800
feat(cli): unify detailed transcript navigation (#5029)
* feat(cli): unify detailed transcript navigation
Generated-by: Codex
* fix(cli): reset transcript reader across new sessions
Generated-by: Codex
---
packages/cli/src/__tests__/pi-tui-runner.test.ts | 280 ++++++++++++++++++++-
.../src/__tests__/pi-tui-transcript-viewer.test.ts | 112 +++++++--
packages/cli/src/pi-tui-layout.ts | 44 +++-
packages/cli/src/pi-tui-runner.ts | 50 ++--
packages/cli/src/pi-tui-transcript-viewer.ts | 161 +++++++++++-
packages/cli/src/tui-copy-catalog.ts | 29 ++-
6 files changed, 615 insertions(+), 61 deletions(-)
diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts
index 6e74657164..b158b95953 100644
--- a/packages/cli/src/__tests__/pi-tui-runner.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts
@@ -2541,7 +2541,7 @@ describe('Maka Pi TUI runner', () => {
assert.equal(unsubscribed, true);
});
- test('keeps tool expansion when kitty protocol reports the Ctrl-O release', async () => {
+ test('keeps the detailed reader open on key release and preserves the draft on close', async () => {
const terminal = new FakeTerminal();
const driver = new ToolOutputDriver();
const run = runMakaPiTui({
@@ -2564,15 +2564,78 @@ describe('Maka Pi TUI runner', () => {
terminal.input('\x1b[111;5u');
terminal.input('\x1b[111;5:3u');
- // The compact-only annotation leaving the screen proves the card is
- // still expanded after the release event.
- await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('(31 lines)'));
- // Sentinel render ordered after the release event: if the release had
- // collapsed the card back, this frame would show the annotation again.
- terminal.input('z');
- await waitFor(() => editorInputText(terminal) === 'z');
- assert.equal(plainTerminalOutput(terminal.screenOutput()).includes('(31 lines)'), false);
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('\x05');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('(31 lines)'));
+ terminal.input('\x0f');
+ await waitFor(
+ () => !plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('draft');
+ await waitFor(() => editorInputText(terminal) === 'draft');
+ terminal.input('\x0f');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('\x0f');
+ await waitFor(() => editorInputText(terminal) === 'draft');
+ terminal.input('\x15');
+
+ exitMaka(terminal);
+ await Promise.race([
+ run,
+ delay(CLOSE_BUDGET_MS).then(() => {
+ throw new Error('TUI did not close during test cleanup');
+ }),
+ ]);
+ });
+
+ test('hides the reader for a pending question and reopens it without losing the draft', async () => {
+ const terminal = new FakeTerminal();
+ const driver = new ReaderThenQuestionDriver();
+ const run = runMakaPiTui({
+ title: 'Maka',
+ driver,
+ cwd: '/repo',
+ model: 'claude-sonnet-4-5',
+ connectionSlug: 'claude-subscription',
+ permissionMode: 'ask',
+ terminal,
+ });
+ terminal.input('run');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('(31 lines)'));
+ terminal.input('draft');
+ await waitFor(() => editorInputText(terminal) === 'draft');
+ terminal.input('\x0f');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+
+ driver.releaseQuestion();
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('Choose an approach'),
+ );
+ assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /DETAILED TRANSCRIPT/);
+
+ terminal.input('\r');
+ await waitFor(() => driver.responses.length === 1);
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('(31 lines)'));
+ assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /DETAILED TRANSCRIPT/);
+ terminal.input('\x0f');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('\x0f');
+ await waitFor(
+ () => !plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ assert.equal(editorInputText(terminal), 'draft');
+
+ terminal.input('\x15');
exitMaka(terminal);
await Promise.race([
run,
@@ -2913,7 +2976,7 @@ describe('Maka Pi TUI runner', () => {
'the transcript viewer to open',
);
let screen = plainTerminalOutput(terminal.screenOutput());
- assert.match(screen, /PgUp\/PgDn page/);
+ assert.match(screen, /PgUp\/PgDn/);
assert.match(screen, /filler line 40/);
assert.doesNotMatch(screen, /filler line 1\s/);
@@ -2939,6 +3002,18 @@ describe('Maka Pi TUI runner', () => {
/Maka · Auto · deepseek-v4-flash · deepseek · \/repo/,
);
+ // Reopening the same session preserves the reader position.
+ terminal.input('\x0f');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ assert.match(plainTerminalOutput(terminal.screenOutput()), /filler line 1/);
+ assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /filler line 40/);
+ terminal.input('q');
+ await waitFor(
+ () => !plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+
exitMaka(terminal);
await Promise.race([
run,
@@ -2948,6 +3023,99 @@ describe('Maka Pi TUI runner', () => {
]);
});
+ test('resets the cached reader after successful /new with non-persisted session ids', async () => {
+ const terminal = new FakeTerminal();
+ const driver = new NonPersistedLongTranscriptDriver();
+ const run = runMakaPiTui({
+ title: 'Maka',
+ driver,
+ cwd: '/repo',
+ model: 'deepseek-v4-flash',
+ connectionSlug: 'deepseek',
+ permissionMode: 'ask',
+ terminal,
+ });
+
+ terminal.input('/help');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.output()).includes('Keybindings'));
+ driver.dropSessionId();
+ terminal.input('/transcript');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('\x1b[H');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('/help'));
+ terminal.input('q');
+ await waitFor(
+ () => !plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+
+ terminal.input('/new');
+ terminal.input('\r');
+ await waitFor(() => driver.startNewSessionCalls === 1);
+ await delay(0);
+ terminal.input('/help');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.output()).includes('Keybindings'));
+ terminal.input('/transcript');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ const screen = plainTerminalOutput(terminal.screenOutput());
+ assert.match(screen, /Ctrl\+D/);
+
+ terminal.input('q');
+ exitMaka(terminal);
+ await run;
+ });
+
+ test('does not reset the cached reader when /new fails', async () => {
+ const terminal = new FakeTerminal();
+ const driver = new FailingNewSessionLongTranscriptDriver();
+ const run = runMakaPiTui({
+ title: 'Maka',
+ driver,
+ cwd: '/repo',
+ model: 'deepseek-v4-flash',
+ connectionSlug: 'deepseek',
+ permissionMode: 'ask',
+ terminal,
+ });
+
+ terminal.input('/help');
+ terminal.input('\r');
+ await waitFor(() => plainTerminalOutput(terminal.output()).includes('Keybindings'));
+ terminal.input('/transcript');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ terminal.input('\x1b[H');
+ await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('/help'));
+ terminal.input('q');
+ await waitFor(
+ () => !plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+
+ terminal.input('/new');
+ terminal.input('\r');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('Could not start a new session'),
+ );
+ terminal.input('\x0f');
+ await waitFor(() =>
+ plainTerminalOutput(terminal.screenOutput()).includes('DETAILED TRANSCRIPT'),
+ );
+ assert.match(plainTerminalOutput(terminal.screenOutput()), /\/help/);
+
+ terminal.input('q');
+ exitMaka(terminal);
+ await run;
+ });
+
test('opens the read-only /mcp status on an idle local TUI', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
@@ -9625,6 +9793,80 @@ class ToolOutputDriver extends FakeSessionDriver {
}
}
+class ReaderThenQuestionDriver extends ToolOutputDriver {
+ readonly responses: UserQuestionResponse[] = [];
+ private release: (() => void) | undefined;
+ private complete: (() => void) | undefined;
+
+ override async *promptEvents(_prompt: string): AsyncIterable<SessionEvent> {
+ yield {
+ type: 'tool_start',
+ id: 'event-tool-start',
+ turnId: 'turn-1',
+ ts: 1,
+ toolUseId: 'tool-1',
+ toolName: 'Bash',
+ args: { command: 'npm test' },
+ };
+ yield {
+ type: 'tool_result',
+ id: 'event-tool-result',
+ turnId: 'turn-1',
+ ts: 2,
+ toolUseId: 'tool-1',
+ isError: false,
+ content: {
+ kind: 'terminal',
+ cwd: '/repo',
+ cmd: 'npm test',
+ status: 'completed',
+ exitCode: 0,
+ output: pipeOutput(
+ `expanded-tail\n${Array.from({ length: 30 }, (_, i) => `row-${i}`).join('\n')}`,
+ ),
+ },
+ };
+ await new Promise<void>((resolve) => {
+ this.release = resolve;
+ });
+ yield {
+ type: 'user_question_request',
+ id: 'event-question',
+ turnId: 'turn-1',
+ ts: 3,
+ requestId: 'question-1',
+ toolUseId: 'tool-question',
+ questions: [
+ {
+ question: 'Choose an approach',
+ options: [{ label: 'Extend' }, { label: 'Separate' }],
+ },
+ ],
+ };
+ await new Promise<void>((resolve) => {
+ this.complete = resolve;
+ });
+ yield {
+ type: 'complete',
+ id: 'event-complete',
+ turnId: 'turn-1',
+ ts: 4,
+ stopReason: 'end_turn',
+ };
+ }
+
+ releaseQuestion(): void {
+ this.release?.();
+ this.release = undefined;
+ }
+
+ async respondToUserQuestion(response: UserQuestionResponse): Promise<void> {
+ this.responses.push(response);
+ this.complete?.();
+ this.complete = undefined;
+ }
+}
+
// #4011: an 80-line thinking block renders in full when expanded, so one
// Ctrl+T pushes its head above the 24-row viewport into scrollback.
class ThinkingDriver extends ToolOutputDriver {
@@ -10650,6 +10892,24 @@ class LongTranscriptDriver extends SlashCommandDriver {
}
}
+class NonPersistedLongTranscriptDriver extends LongTranscriptDriver {
+ private persisted = true;
+
+ override getSessionId(): string | null {
+ return this.persisted ? super.getSessionId() : null;
+ }
+
+ dropSessionId(): void {
+ this.persisted = false;
+ }
+}
+
+class FailingNewSessionLongTranscriptDriver extends NonPersistedLongTranscriptDriver {
+ override async startNewSession(): Promise<void> {
+ throw new Error('new session failed');
+ }
+}
+
class DeferredControlDriver extends FakeSessionDriver {
readonly prompts: string[] = [];
readonly models: string[] = [];
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 bc6443dafc..e982084b75 100644
--- a/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts
+++ b/packages/cli/src/__tests__/pi-tui-transcript-viewer.test.ts
@@ -19,6 +19,7 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
+import { CURSOR_MARKER } from '@earendil-works/pi-tui';
import { TranscriptViewerOverlay } from '../pi-tui-transcript-viewer.js';
import { MakaTranscriptComponent } from '../pi-tui-layout.js';
import { createMakaPiTranscriptState } from '../pi-transcript.js';
@@ -30,7 +31,7 @@ describe('TranscriptViewerOverlay', () => {
let changes = 0;
let closed = 0;
const viewer = new TranscriptViewerOverlay({
- renderTranscript: () => document,
+ renderTranscript: () => ({ lines: document, anchors: [] }),
viewportRows: () => 6,
onChange: () => {
changes += 1;
@@ -85,7 +86,7 @@ describe('TranscriptViewerOverlay', () => {
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,
+ renderTranscript: () => ({ lines: document, anchors: [] }),
viewportRows: () => 5,
onChange: () => {},
onClose: () => {},
@@ -123,7 +124,7 @@ 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,
+ renderTranscript: () => ({ lines: document, anchors: [] }),
viewportRows: () => 6,
onChange: () => {},
onClose: () => {},
@@ -141,11 +142,11 @@ describe('TranscriptViewerOverlay', () => {
]);
});
- test('resumes following after a resize clamps a detached viewport to the tail', () => {
+ test('does not re-enable following merely because resizing reaches the tail', () => {
const document = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
let viewportRows = 6;
const viewer = new TranscriptViewerOverlay({
- renderTranscript: () => document,
+ renderTranscript: () => ({ lines: document, anchors: [] }),
viewportRows: () => viewportRows,
onChange: () => {},
onClose: () => {},
@@ -158,18 +159,18 @@ describe('TranscriptViewerOverlay', () => {
document.push('line 13');
assert.deepEqual(plain(viewer.render(30)).slice(1, -1).map(trim), [
+ 'line 8',
'line 9',
'line 10',
'line 11',
'line 12',
- 'line 13',
]);
});
test('closes with q or Escape', () => {
let closed = 0;
const viewer = new TranscriptViewerOverlay({
- renderTranscript: () => [],
+ renderTranscript: () => ({ lines: [], anchors: [] }),
viewportRows: () => 4,
onChange: () => {},
onClose: () => {
@@ -186,21 +187,23 @@ describe('TranscriptViewerOverlay', () => {
const document = ['line 1', 'line 2', 'line 3'];
let viewportRows = 2;
const viewer = new TranscriptViewerOverlay({
- renderTranscript: () => document,
+ renderTranscript: () => ({ lines: document, anchors: [] }),
viewportRows: () => viewportRows,
onChange: () => {},
onClose: () => {},
});
- assert.deepEqual(plain(viewer.render(30)).map(trim), ['TRANSCRIPT 3-3 of 3', 'line 3']);
+ assert.match(stripAnsi(viewer.render(30)[0]!), /DETAILED TRANSCRIPT 3-3\/3/);
+ assert.equal(trim(stripAnsi(viewer.render(30)[1]!)), 'line 3');
viewportRows = 1;
- assert.deepEqual(plain(viewer.render(30)).map(trim), ['TRANSCRIPT 0-0 of 3']);
+ assert.match(stripAnsi(viewer.render(30)[0]!), /DETAILED TRANSCRIPT 0-0\/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/);
+ assert.match(resized[0]!, /DETAILED TRANSCRIPT 2-3\/3/);
+ assert.deepEqual(resized.slice(1, 3), ['line 2', 'line 3']);
+ assert.match(resized[3] ?? '', /Esc\/Ctrl\+O close/);
});
test('renders through a detached geometry projection', () => {
@@ -218,11 +221,92 @@ describe('TranscriptViewerOverlay', () => {
}));
const renderDocument = transcript.createDocumentRenderer();
- assert.ok(plain(renderDocument(40)).some((line) => line.includes('oldest prompt')));
+ assert.ok(plain(renderDocument(40).lines).some((line) => line.includes('oldest prompt')));
assert.equal(state.renderGeometry.viewportTop, 16);
assert.strictEqual(state.renderGeometry.entryFirstLine, entryFirstLine);
});
+ test('keeps the same entry when earlier details collapse and new text arrives', () => {
+ let extra = 0;
+ const viewer = new TranscriptViewerOverlay({
+ renderTranscript: (_, expanded) => {
+ const before = expanded ? ['tool A', 'detail 1', 'detail 2', 'detail 3'] : ['tool A'];
+ return {
+ lines: [...before, 'answer B', 'answer line', 'tail', ...Array(extra).fill('new output')],
+ anchors: [
+ { id: 'a', line: 0 },
+ { id: 'b', line: before.length },
+ { id: 'c', line: before.length + 2 },
+ ],
+ };
+ },
+ viewportRows: () => 4,
+ onClose: () => {},
+ onChange: () => {},
+ });
+ viewer.render(80);
+ viewer.handleInput('\x1b[A');
+ assert.match(stripAnsi(viewer.render(80)[1]!), /answer B/);
+ viewer.handleInput('\x05');
+ extra = 5;
+ assert.match(stripAnsi(viewer.render(80)[1]!), /answer B/);
+ viewer.handleInput('\x05');
+ assert.match(stripAnsi(viewer.render(80)[1]!), /answer B/);
+ });
+
+ test('search locates Chinese text without filtering the document and Escape restores reading', () => {
+ const lines = Array.from({ length: 20 }, (_, i) => (i === 4 ? '账号隔离' : `line ${i}`));
+ const viewer = new TranscriptViewerOverlay({
+ renderTranscript: () => ({ lines, anchors: [] }),
+ viewportRows: () => 6,
+ onClose: () => assert.fail('search Escape must not close the viewer'),
+ onChange: () => {},
+ });
+ const before = plain(viewer.render(80)).slice(1, -1);
+ viewer.handleInput('/');
+ viewer.handleInput('账号');
+ assert.match(stripAnsi(viewer.render(80)[1]!), /账号隔离/);
+ viewer.handleInput('\r');
+ viewer.handleInput('\x1b[B');
+ assert.match(stripAnsi(viewer.render(80)[1]!), /line 5/);
+ viewer.handleInput('\x1b');
+ assert.deepEqual(plain(viewer.render(80)).slice(1, -1), before);
+ });
+
+ test('focuses the search input and emits the hardware cursor marker', () => {
+ const viewer = new TranscriptViewerOverlay({
+ renderTranscript: () => ({ lines: ['account isolation'], anchors: [] }),
+ viewportRows: () => 6,
+ onClose: () => {},
+ onChange: () => {},
+ });
+ viewer.focused = true;
+ viewer.handleInput('/');
+ assert.ok(viewer.render(80).some((line) => line.includes(CURSOR_MARKER)));
+ });
+
+ test('expanding document tools and thinking never changes the live entries', () => {
+ const state = createMakaPiTranscriptState();
+ const thinking = {
+ kind: 'thinking' as const,
+ messageId: 'm',
+ text: 'private reasoning',
+ expanded: false,
+ };
+ state.entries.push(thinking);
+ const transcript = new MakaTranscriptComponent(state, () => ({
+ title: 'Maka',
+ cwd: '/repo',
+ model: 'model',
+ connectionSlug: 'connection',
+ permissionMode: 'ask',
+ }));
+ const render = transcript.createDocumentRenderer();
+ assert.match(plain(render(80, true).lines).join('\n'), /private reasoning/);
+ assert.equal(thinking.expanded, false);
+ assert.doesNotMatch(plain(render(80, false).lines).join('\n'), /private reasoning/);
+ });
+
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' };
@@ -239,7 +323,7 @@ describe('TranscriptViewerOverlay', () => {
state.renderGeometry.viewportTop = 100;
entry.text = 'background update';
const renderDocument = transcript.createDocumentRenderer();
- assert.ok(plain(renderDocument(40)).some((line) => line.includes('background update')));
+ assert.ok(plain(renderDocument(40).lines).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 f76c6f4dbf..ef978da164 100644
--- a/packages/cli/src/pi-tui-layout.ts
+++ b/packages/cli/src/pi-tui-layout.ts
@@ -32,6 +32,7 @@ import {
type MakaPiTranscriptMetadata,
type MakaPiTranscriptState,
} from './pi-transcript.js';
+import type { TranscriptDocument } from './pi-tui-transcript-viewer.js';
interface ViewportAwareEditor extends Component {
setViewportRows(rows: number): void;
@@ -63,9 +64,9 @@ export class MakaTranscriptComponent implements Component {
* 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.
+ createDocumentRenderer(): (width: number, expanded?: boolean) => TranscriptDocument {
+ // The detached keys and their rendered-line cache belong to one Session's
+ // reader. Reopening keeps its position; switching Sessions releases it.
const entryClones = new WeakMap<MakaPiTranscriptEntry, MakaPiTranscriptEntry>();
const documentEntry = (entry: MakaPiTranscriptEntry): MakaPiTranscriptEntry => {
const cached = entryClones.get(entry);
@@ -77,16 +78,33 @@ export class MakaTranscriptComponent implements Component {
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,
- );
+ return (width, expanded) => {
+ const entries = this.state.entries.map(documentEntry);
+ if (expanded !== undefined) {
+ for (const entry of entries) {
+ if (entry.kind === 'tool' || entry.kind === 'thinking') entry.expanded = expanded;
+ }
+ }
+ const detachedState: MakaPiTranscriptState = {
+ ...this.state,
+ entries,
+ renderGeometry: { entryFirstLine: undefined, viewportTop: 0 },
+ };
+ const lines = renderMakaPiTranscript(detachedState, this.metadata(), width);
+ const anchors = entries.flatMap((entry, index) => {
+ if (entry.kind === 'tool' && entry.suppressed) return [];
+ const line = detachedState.renderGeometry.entryFirstLine?.get(entry);
+ if (line === undefined) return [];
+ const id =
+ entry.kind === 'tool'
+ ? `tool:${entry.turnId ?? ''}:${entry.toolUseId}`
+ : 'messageId' in entry
+ ? `${entry.kind}:${entry.messageId}`
+ : `${entry.kind}:${index}`;
+ return [{ id, line }];
+ });
+ return { lines, anchors };
+ };
}
}
diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts
index 01e4856765..99d564b860 100644
--- a/packages/cli/src/pi-tui-runner.ts
+++ b/packages/cli/src/pi-tui-runner.ts
@@ -563,6 +563,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// True while the /session picker is open mid-turn: Escape must close the
// overlay, not arm the double-Escape interrupt for the running Turn (#3380).
let sessionPickerOverlayOpen = false;
+ let transcriptOverlay: OverlayHandle | undefined;
+ let transcriptViewer: TranscriptViewerOverlay | undefined;
+ let transcriptViewerSessionId: string | undefined;
+ const resetTranscriptViewer = (): void => {
+ transcriptOverlay?.hide();
+ transcriptOverlay = undefined;
+ transcriptViewer = undefined;
+ transcriptViewerSessionId = undefined;
+ };
let lastTurnEscapeAt = 0;
let lastIdleEscapeAt = 0;
let lastIdleCtrlCAt = 0;
@@ -1739,6 +1748,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
messages,
activeTurn,
}: MakaSessionSwitchResult): Promise<void> => {
+ resetTranscriptViewer();
adoptSessionMetadata(summary, false);
replaceTranscript(messages);
syncInteractionOverlays();
@@ -2319,6 +2329,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
};
const syncInteractionOverlays = (): void => {
+ if (state.pendingInteraction) {
+ transcriptOverlay?.hide();
+ transcriptOverlay = undefined;
+ }
syncUserQuestionOverlay();
syncFormOverlay();
};
@@ -2930,6 +2944,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
requestRender();
return false;
}
+ resetTranscriptViewer();
// A fresh session is not bound by the previous one's boundary. Falling back
// to the *current* label would keep the previous Session's mode, including
// Auto while a changed Host default creates with full access; the launch
@@ -3009,15 +3024,22 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
};
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, {
+ if (state.pendingInteraction) return;
+ const sessionId = input.driver.getSessionId() ?? undefined;
+ if (!transcriptViewer || transcriptViewerSessionId !== sessionId) {
+ transcriptViewerSessionId = sessionId;
+ transcriptViewer = new TranscriptViewerOverlay({
+ renderTranscript: transcript.createDocumentRenderer(),
+ locale,
+ viewportRows: () => terminal.rows,
+ onClose: () => {
+ transcriptOverlay?.hide();
+ transcriptOverlay = undefined;
+ },
+ onChange: () => tui.requestRender(),
+ });
+ }
+ transcriptOverlay = tui.showOverlay(transcriptViewer, {
anchor: 'top-left',
width: '100%',
maxHeight: '100%',
@@ -4189,9 +4211,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// one (e.g. `Esc`, type, `Esc`).
if (!matchesKey(data, Key.escape)) lastIdleEscapeAt = 0;
if (matchesKey(data, Key.ctrl('o')) && !isKeyRepeat(data)) {
- if (handleExpansionToggleKey('tool')) {
- return { consume: true };
- }
+ showTranscriptViewer();
+ return { consume: true };
}
if (matchesKey(data, Key.ctrl('t')) && !isKeyRepeat(data)) {
if (handleExpansionToggleKey('thinking')) {
@@ -4297,11 +4318,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// re-wrapping) a full clear would wipe the scrollback the user scrolls through.
// Differential rendering clears the vacated rows without the wipe.
//
- // The Ctrl+O / Ctrl+T toggles are viewport-anchored for the same reason: an
+ // The live Ctrl+T thinking toggle is viewport-anchored for the same reason: an
// entry above the live viewport lives in terminal scrollback, which cannot
// be rewritten, so resizing it would push pi-tui's differential renderer
// into a scrollback-clearing full redraw (its `firstChanged < viewportTop`
- // path). The toggles therefore retarget only entries inside the viewport;
+ // path). It therefore retargets only entries inside the viewport;
// see entryInLiveViewport in pi-transcript.ts (#1097). A block whose own
// expansion pushed its head above the viewport can consequently not be
// collapsed by the next press (#1134): the toggle still flips the default
@@ -4310,6 +4331,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
// again within EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS applies the collapsed
// default to those blocks too and pays one scrollback-clearing full redraw
// (requestRender(true)), re-anchoring the viewport at the tail.
+ // Ctrl+O instead opens a detached reader whose details never resize live rows.
tui.setClearOnShrink(false);
tui.addChild(layout);
tui.setFocus(editorSurface);
diff --git a/packages/cli/src/pi-tui-transcript-viewer.ts b/packages/cli/src/pi-tui-transcript-viewer.ts
index d381ba6077..544de72ea1 100644
--- a/packages/cli/src/pi-tui-transcript-viewer.ts
+++ b/packages/cli/src/pi-tui-transcript-viewer.ts
@@ -19,18 +19,29 @@
import {
Key,
+ Input,
+ isKeyRelease,
+ isKeyRepeat,
matchesKey,
truncateToWidth,
visibleWidth,
type Component,
} from '@earendil-works/pi-tui';
-import { ansi } from './tui-ansi.js';
+import type { UiLocale } from '@maka/core/ui-locale';
+import { ansi, stripAnsi } from './tui-ansi.js';
+import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js';
const VIEWER_CHROME_ROWS = 2;
+export interface TranscriptDocument {
+ lines: readonly string[];
+ anchors: readonly { id: string; line: number }[];
+}
+
export interface TranscriptViewerInput {
/** Produces the current read-only CLI transcript projection at this width. */
- renderTranscript(width: number): readonly string[];
+ renderTranscript(width: number, expanded: boolean): TranscriptDocument;
+ locale?: UiLocale;
viewportRows(): number;
onClose(): void;
onChange(): void;
@@ -44,20 +55,98 @@ export interface TranscriptViewerInput {
* not create a second set of global editor bindings or a second history source.
*/
export class TranscriptViewerOverlay implements Component {
+ focused = false;
private top = 0;
private documentRows = 0;
private bodyRows = 0;
private followsEnd = true;
+ private document: TranscriptDocument = { lines: [], anchors: [] };
+ private expanded = true;
+ private anchor: { id: string; offset: number } | undefined;
+ private search = new Input();
+ private searching = false;
+ private query = '';
+ private matches: number[] = [];
+ private searchOrigin:
+ | { top: number; followsEnd: boolean; anchor: { id: string; offset: number } | undefined }
+ | undefined;
+ private matchedLine: number | undefined;
constructor(private readonly input: TranscriptViewerInput) {}
invalidate(): void {}
handleInput(data: string): void {
+ if (isKeyRelease(data)) return;
+ if (matchesKey(data, Key.ctrl('o'))) {
+ if (!isKeyRepeat(data)) this.input.onClose();
+ return;
+ }
+ if (this.searching) {
+ if (matchesKey(data, Key.escape)) {
+ this.searching = false;
+ this.query = '';
+ this.matches = [];
+ if (this.searchOrigin) Object.assign(this, this.searchOrigin);
+ this.input.onChange();
+ return;
+ }
+ if (matchesKey(data, Key.enter)) {
+ this.searching = false;
+ this.input.onChange();
+ return;
+ }
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
+ this.nextMatch(matchesKey(data, Key.up) ? -1 : 1);
+ return;
+ }
+ this.search.handleInput(data);
+ const query = this.search.getValue().trim().toLocaleLowerCase();
+ if (query !== this.query) {
+ this.query = query;
+ this.findMatches();
+ const target =
+ this.matches.find((line) => line >= (this.searchOrigin?.top ?? 0)) ?? this.matches[0];
+ if (target !== undefined) this.goToMatch(target);
+ }
+ this.input.onChange();
+ return;
+ }
+ if (matchesKey(data, Key.escape) && this.query) {
+ this.query = '';
+ this.matches = [];
+ if (this.searchOrigin) Object.assign(this, this.searchOrigin);
+ this.input.onChange();
+ return;
+ }
if (matchesKey(data, Key.escape) || matchesKey(data, 'q')) {
this.input.onClose();
return;
}
+ if (matchesKey(data, '/')) {
+ this.searchOrigin = {
+ top: this.top,
+ followsEnd: this.followsEnd,
+ anchor: this.anchor ? { ...this.anchor } : undefined,
+ };
+ this.search.setValue(this.query);
+ this.searching = true;
+ this.input.onChange();
+ return;
+ }
+ if (this.query && (matchesKey(data, 'n') || matchesKey(data, Key.shift('n')))) {
+ this.nextMatch(matchesKey(data, Key.shift('n')) ? -1 : 1);
+ return;
+ }
+ if (matchesKey(data, Key.ctrl('e'))) {
+ if (!isKeyRepeat(data)) {
+ this.expanded = !this.expanded;
+ // A detail that disappears on collapse returns to its own heading.
+ if (!this.expanded && this.anchor) this.anchor.offset = 0;
+ this.input.onChange();
+ }
+ return;
+ }
if (matchesKey(data, Key.up)) {
this.scrollBy(-1);
return;
@@ -77,12 +166,14 @@ export class TranscriptViewerOverlay implements Component {
if (matchesKey(data, Key.home)) {
this.followsEnd = false;
this.top = 0;
+ this.captureAnchor();
this.input.onChange();
return;
}
if (matchesKey(data, Key.end)) {
this.followsEnd = true;
this.top = this.maxTop();
+ this.captureAnchor();
this.input.onChange();
}
}
@@ -95,41 +186,97 @@ export class TranscriptViewerOverlay implements Component {
const showFooter = viewportRows > 2;
this.bodyRows = Math.max(0, viewportRows - (showFooter ? VIEWER_CHROME_ROWS : 1));
- const document = [...this.input.renderTranscript(safeWidth)];
+ this.document = this.input.renderTranscript(safeWidth, this.expanded);
+ const document = this.document.lines;
this.documentRows = document.length;
+ if (!this.followsEnd && this.anchor) {
+ const index = this.document.anchors.findIndex((entry) => entry.id === this.anchor!.id);
+ if (index >= 0) {
+ const entry = this.document.anchors[index]!;
+ const end = this.document.anchors[index + 1]?.line ?? document.length;
+ this.top = entry.line + Math.min(this.anchor.offset, Math.max(0, end - entry.line - 1));
+ }
+ }
const maxTop = this.maxTop();
this.top = this.followsEnd ? maxTop : clamp(this.top, 0, maxTop);
- this.followsEnd = this.top === maxTop;
+ this.captureAnchor();
+ this.findMatches();
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 copy = TUI_COPY_RESOURCES['transcript-reader'][this.input.locale ?? 'en'];
const header = padLine(
- `${ansi.bold('TRANSCRIPT')} ${ansi.dim(`${start}-${end} of ${document.length}`)}`,
+ `${ansi.bold(copy.title)} ${ansi.dim(`${start}-${end}/${document.length} · ${copy.scope}`)}`,
safeWidth,
);
+ const matchingLines = new Set(this.matches);
const body = [
- ...visible.map((line) => padLine(line, safeWidth)),
+ ...visible.map((line, index) =>
+ padLine(
+ matchingLines.has(this.top + index) ? ansi.reverse(stripAnsi(line)) : line,
+ safeWidth,
+ ),
+ ),
...Array.from({ length: Math.max(0, this.bodyRows - visible.length) }, () =>
' '.repeat(safeWidth),
),
];
if (!showFooter) return [header, ...body];
+ this.search.focused = this.focused && this.searching;
const footer = padLine(
- ansi.dim('↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close'),
+ this.searching
+ ? `/ ${this.search.render(Math.max(1, safeWidth - 3))[0] ?? ''}`
+ : ansi.dim(
+ this.query
+ ? `${this.matches.length} ${copy.matches} · n/N · Esc ${copy.back}`
+ : copy.hint,
+ ),
safeWidth,
);
return [header, ...body, footer];
}
private scrollBy(delta: number): void {
+ this.matchedLine = undefined;
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.captureAnchor();
+ this.input.onChange();
+ }
+
+ private captureAnchor(): void {
+ const entry = [...this.document.anchors].reverse().find((entry) => entry.line <= this.top);
+ this.anchor = entry ? { id: entry.id, offset: this.top - entry.line } : undefined;
+ }
+
+ private findMatches(): void {
+ this.matches = this.query
+ ? this.document.lines.flatMap((line, index) =>
+ stripAnsi(line).toLocaleLowerCase().includes(this.query) ? [index] : [],
+ )
+ : [];
+ }
+
+ private goToMatch(line: number): void {
+ this.matchedLine = line;
+ this.top = clamp(line, 0, this.maxTop());
+ this.followsEnd = false;
+ this.captureAnchor();
+ }
+
+ private nextMatch(direction: number): void {
+ const current = this.matchedLine ?? this.top;
+ const line =
+ direction > 0
+ ? (this.matches.find((line) => line > current) ?? this.matches[0])
+ : ([...this.matches].reverse().find((line) => line < current) ?? this.matches.at(-1));
+ if (line !== undefined) this.goToMatch(line);
this.input.onChange();
}
diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts
index 376ea6d395..1a75c1dcea 100644
--- a/packages/cli/src/tui-copy-catalog.ts
+++ b/packages/cli/src/tui-copy-catalog.ts
@@ -18,6 +18,29 @@
*/
export const TUI_COPY_RESOURCES = {
+ 'transcript-reader': {
+ en: {
+ title: 'DETAILED TRANSCRIPT',
+ scope: 'loaded history',
+ hint: 'Esc/Ctrl+O close · Ctrl+E details · / search · ↑↓/PgUp/PgDn · Home/End',
+ matches: 'matches in displayed text',
+ back: 'return',
+ },
+ 'zh-CN': {
+ title: '详细记录',
+ scope: '已加载历史',
+ hint: 'Esc/Ctrl+O 返回 · Ctrl+E 详情 · / 搜索 · ↑↓/PgUp/PgDn · Home/End',
+ matches: '处匹配(当前显示内容)',
+ back: '回到原位置',
+ },
+ 'zh-TW': {
+ title: '詳細記錄',
+ scope: '已載入歷史',
+ hint: 'Esc/Ctrl+O 返回 · Ctrl+E 詳情 · / 搜尋 · ↑↓/PgUp/PgDn · Home/End',
+ matches: '處符合(目前顯示內容)',
+ back: '回到原位置',
+ },
+ },
'host-owner': {
en: {
unavailable: 'Runtime Host owner controls are unavailable.',
@@ -913,7 +936,7 @@ export const TUI_COPY_RESOURCES = {
userCommand: ' !<command> — run one shell command visible only to you',
keybindingsHeading: 'Keybindings',
keybindings: [
- ' Ctrl+O — expand or collapse all tool output',
+ ' Ctrl+O — open detailed transcript (Ctrl+E toggles details inside)',
' Ctrl+T — expand or collapse all thinking in view',
' Scroll the transcript with your terminal or trackpad',
' Enter (during a turn) — steer: inject a message into the running turn',
@@ -966,7 +989,7 @@ export const TUI_COPY_RESOURCES = {
userCommand: ' !<command> — 执行一次仅用户可见的 shell 命令',
keybindingsHeading: '快捷键',
keybindings: [
- ' Ctrl+O — 展开或折叠所有工具输出',
+ ' Ctrl+O — 打开详细记录(其中 Ctrl+E 切换详情)',
' Ctrl+T — 展开或折叠视图中的所有思考块',
' 使用终端或触控板滚动对话记录',
' Enter(任务运行中)— 将消息注入当前任务',
@@ -1019,7 +1042,7 @@ export const TUI_COPY_RESOURCES = {
userCommand: ' !<command> — 執行一次僅使用者可見的 shell 命令',
keybindingsHeading: '快捷鍵',
keybindings: [
- ' Ctrl+O — 展開或摺疊所有工具輸出',
+ ' Ctrl+O — 開啟詳細記錄(其中 Ctrl+E 切換詳情)',
' Ctrl+T — 展開或摺疊檢視中的所有思考區塊',
' 使用終端機或觸控板捲動對話記錄',
' Enter(任務執行中)— 將訊息注入目前任務',
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org