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