| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { Container, type Component, type Terminal } from '@earendil-works/pi-tui'; |
| import type { UiLocale } from '@maka/core/ui-locale'; |
| // 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 MakaPiTranscriptEntry, |
| 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; |
| isShowingAutocomplete(): boolean; |
| minimumViewportRows(): number; |
| } |
| |
| export function fitPendingQueueLines(lines: readonly string[], maxRows: number): string[] { |
| const rowBudget = Math.max(0, Math.floor(maxRows)); |
| if (lines.length <= rowBudget) return [...lines]; |
| if (rowBudget === 0) return []; |
| if (rowBudget === 1) return [`… ${lines.length} more`]; |
| return [...lines.slice(0, rowBudget - 1), `… ${lines.length - rowBudget + 1} more`]; |
| } |
| |
| 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); |
| } |
| |
| /** |
| * Render the complete current projection without changing the geometry used |
| * by the live terminal-scrollback reconciliation path. |
| */ |
| 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); |
| if (cached) { |
| Object.assign(cached, entry); |
| return cached; |
| } |
| const clone = { ...entry } as MakaPiTranscriptEntry; |
| entryClones.set(entry, clone); |
| return clone; |
| }; |
| 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 }; |
| }; |
| } |
| } |
| |
| 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, |
| private readonly locale: UiLocale, |
| ) {} |
| |
| invalidate(): void {} |
| |
| render(width: number): string[] { |
| return renderMakaPiPendingQueue(this.state, width, process.platform, this.locale); |
| } |
| } |
| |
| /** |
| * 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: ViewportAwareEditor, |
| private readonly statusLine: Component, |
| private readonly terminal: Terminal, |
| private readonly todoIndicator?: Component, |
| ) { |
| super(); |
| this.addChild(transcript); |
| this.addChild(activityStrip); |
| this.addChild(pendingQueue); |
| if (todoIndicator) this.addChild(todoIndicator); |
| this.addChild(editor); |
| this.addChild(statusLine); |
| } |
| |
| render(width: number): string[] { |
| const transcriptLines = this.transcript.render(width); |
| const activityLines = this.activityStrip.render(width); |
| const allPendingLines = this.pendingQueue.render(width); |
| const statusLines = this.statusLine.render(width); |
| // Supplementary information must not steal the composer's minimum space. |
| const todoLines = |
| this.terminal.rows > |
| activityLines.length + |
| allPendingLines.length + |
| statusLines.length + |
| this.editor.minimumViewportRows() |
| ? (this.todoIndicator?.render(width) ?? []).slice(0, 1) |
| : []; |
| const pendingRowsAvailable = this.editor.isShowingAutocomplete() |
| ? Math.max( |
| 0, |
| this.terminal.rows - |
| activityLines.length - |
| statusLines.length - |
| todoLines.length - |
| this.editor.minimumViewportRows(), |
| ) |
| : allPendingLines.length; |
| const pendingLines = fitPendingQueueLines(allPendingLines, pendingRowsAvailable); |
| this.editor.setViewportRows( |
| this.terminal.rows - |
| activityLines.length - |
| pendingLines.length - |
| statusLines.length - |
| todoLines.length, |
| ); |
| const editorLines = this.editor.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 + |
| todoLines.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, |
| ...todoLines, |
| ...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); |
| } |
| } |