blob: edde3af7128daee1fa9964d7cdee8ad87406768d [file]
import { Container, type Component, type Terminal } from '@earendil-works/pi-tui';
// Deep import (pi-tui does not re-export it): the viewport shadow diff must
// compare the same canonical lines pi-tui diffs, and pi-tui normalizes Thai/Lao
// AM sequences before its diff. Pinned to pi-tui 0.80.3.
import { normalizeTerminalOutput } from '@earendil-works/pi-tui/dist/utils.js';
import {
renderMakaPiActivityStrip,
renderMakaPiPendingQueue,
renderMakaPiStatusLine,
renderMakaPiTranscript,
type MakaPiTranscriptMetadata,
type MakaPiTranscriptState,
} from './pi-transcript.js';
export class MakaTranscriptComponent implements Component {
constructor(
private readonly state: MakaPiTranscriptState,
private readonly metadata: () => MakaPiTranscriptMetadata,
) {}
invalidate(): void {}
render(width: number): string[] {
return renderMakaPiTranscript(this.state, this.metadata(), width);
}
}
export class MakaStatusLineComponent implements Component {
constructor(private readonly metadata: () => MakaPiTranscriptMetadata) {}
invalidate(): void {}
render(width: number): string[] {
return [renderMakaPiStatusLine(this.metadata(), width)];
}
}
export class MakaActivityStripComponent implements Component {
constructor(private readonly metadata: () => MakaPiTranscriptMetadata) {}
invalidate(): void {}
render(width: number): string[] {
return [renderMakaPiActivityStrip(this.metadata(), width)];
}
}
/** The pending-queue bar (Steering:/Queued:) rendered just above the editor. */
export class MakaPendingQueueComponent implements Component {
constructor(private readonly state: MakaPiTranscriptState) {}
invalidate(): void {}
render(width: number): string[] {
return renderMakaPiPendingQueue(this.state, width);
}
}
/**
* Stacks the transcript above the editor and status line. The transcript is
* never windowed: every line is emitted and, when the whole document is taller
* than the terminal, pi-tui's differential renderer scrolls older output into
* the terminal's own scrollback (exactly as the upstream Pi TUI does). History
* is scrolled with the terminal/trackpad rather than an in-app pager, so long
* output is never truncated.
*
* The only layout work is bottom-anchoring: while the transcript fits, blank
* rows pad it up so the editor and status line sit at the bottom of the screen.
* Once it overflows the padding is gone and the buffer grows past the viewport.
*/
export class MakaPiLayoutComponent extends Container {
/** Composed lines of the previous render, for the viewport-top shadow diff. */
private previousLines: string[] | undefined;
private previousRows: number | undefined;
private previousWidth: number | undefined;
constructor(
private readonly state: MakaPiTranscriptState,
private readonly transcript: MakaTranscriptComponent,
private readonly activityStrip: MakaActivityStripComponent,
private readonly pendingQueue: MakaPendingQueueComponent,
private readonly editor: Component,
private readonly statusLine: Component,
private readonly terminal: Terminal,
) {
super();
this.addChild(transcript);
this.addChild(activityStrip);
this.addChild(pendingQueue);
this.addChild(editor);
this.addChild(statusLine);
}
render(width: number): string[] {
const transcriptLines = this.transcript.render(width);
const activityLines = this.activityStrip.render(width);
const pendingLines = this.pendingQueue.render(width);
const editorLines = this.editor.render(width);
const statusLines = this.statusLine.render(width);
// #1064: when the activity strip is showing (a turn is running), separate
// it from the last transcript line with a blank row. Without this, a
// thinking or tool row (the agent-work stack, which has no internal blank
// gaps) sits directly against `Working… 12s`.
const activityActive =
activityLines.length > 0 && activityLines.some((line) => line.length > 0);
const lastTranscriptLine = transcriptLines[transcriptLines.length - 1];
const needGap =
activityActive && lastTranscriptLine !== undefined && lastTranscriptLine.length > 0;
const paddedTranscript = needGap ? [...transcriptLines, ''] : transcriptLines;
const chromeRows =
activityLines.length + pendingLines.length + editorLines.length + statusLines.length;
const viewportRows = Math.max(0, this.terminal.rows - chromeRows);
const paddingRows = Math.max(0, viewportRows - paddedTranscript.length);
const lines = [
...paddedTranscript,
...Array.from({ length: paddingRows }, () => ''),
...activityLines,
...pendingLines,
...editorLines,
...statusLines,
];
// #1097: record where pi-tui's live viewport starts for this render, in
// transcript-line coordinates (valid because the transcript opens this
// composed list at line 0). The expansion toggles use it to leave entries
// above the viewport untouched — their lines sit in scrollback, which
// cannot be rewritten without a scrollback-clearing full redraw.
//
// Shadow pi-tui's own viewport rule rather than guessing: its viewport
// never scrolls back up (monotonic max) except when it full-redraws and
// re-anchors to the document tail. Each branch of nextViewportTop mirrors
// one decision in pi-tui's doRender (tui.js, pinned 0.80.3); the estimate
// may exceed the real viewport top (which only makes the toggles more
// conservative) but must never fall below it. An upstream viewport getter
// would collapse all of this to one line.
const normalized = lines.map(normalizeTerminalOutput);
this.state.renderGeometry.viewportTop = this.nextViewportTop(normalized, width);
this.previousLines = normalized;
this.previousRows = this.terminal.rows;
this.previousWidth = width;
return lines;
}
/** `lines` are normalized, matching what pi-tui's differential renderer diffs. */
private nextViewportTop(lines: string[], width: number): number {
const rows = this.terminal.rows;
const tailTop = Math.max(0, lines.length - rows);
const previous = this.previousLines;
// First render; width changes full-redraw unconditionally (tui.js ~1061),
// even when no line ends up wrapping differently.
if (previous === undefined || this.previousWidth !== width) return tailTop;
const current = this.state.renderGeometry.viewportTop;
if (this.previousRows !== rows) {
// Height changes full-redraw (tui.js ~1069) except under Termux, where
// the software keyboard resizes constantly and pi-tui instead keeps the
// buffer and recomputes its top from it (tui.js ~983).
return Boolean(process.env.TERMUX_VERSION)
? Math.max(tailTop, current + (this.previousRows ?? rows) - rows)
: tailTop;
}
// Any change above the viewport top forces a full redraw (tui.js ~1169).
const scan = Math.min(previous.length, lines.length);
let firstChanged = -1;
for (let i = 0; i < scan; i += 1) {
if (previous[i] !== lines[i]) {
firstChanged = i;
break;
}
}
if (firstChanged !== -1 && firstChanged < current) return tailTop;
if (lines.length < previous.length) {
// Pure truncation: pi-tui's deleted-lines path full-redraws when the
// new document ends at or above the viewport top (tui.js ~1122,
// `targetRow < prevViewportTop`) or when more than a screenful of rows
// must be cleared (tui.js ~1136); a shallower truncation keeps the
// viewport where it was.
if (lines.length <= current) return tailTop;
if (firstChanged === -1 && previous.length - lines.length > rows) return tailTop;
}
return Math.max(current, tailTop);
}
}