Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b4d619a61This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Bottom-of-viewport dock built on DECSTBM scroll regions: content is fenced
* into rows [1, H - dockHeight] and the dock is painted with absolute cursor
* addressing below the scroll margin, so no amount of content output (or
* mouse wheel) can ever move it. Runs inside the alternate screen buffer the
* REPL enters at startup. This is the FrankenTUI/ratatui-inline technique;
* see also DECSTBM (CSI r).
*/
import { truncateVisible } from '../agent/box.js';
const HIDE = '\x1b[?25l';
const SHOW = '\x1b[?25h';
/** Synchronized-output guards; terminals without support ignore them. */
const SYNC_ON = '\x1b[?2026h';
const SYNC_OFF = '\x1b[?2026l';
/** Reset the scroll region to the full screen. Homes the cursor (hence DECSC/DECRC around it). */
const REGION_RESET = '\x1b[r';
const SAVE = '\x1b7';
const RESTORE = '\x1b8';
export class TerminalDock {
/** Rows the dock currently occupies at the bottom of the screen. */
private dockH = 0;
/**
* True while the real cursor is parked at a caret position inside the dock
* (the DECSC slot then holds the content-region position to return to).
*/
private parked = false;
/** Last set() payload, replayable after an external overdraw (animations). */
private last: { lines: string[]; caret?: { row: number; col: number } } | null = null;
constructor(private readonly out: NodeJS.WriteStream) {}
cols(): number {
return typeof this.out.columns === 'number' && this.out.columns ? this.out.columns : 80;
}
rows(): number {
return typeof this.out.rows === 'number' && this.out.rows ? this.out.rows : 24;
}
/**
* Replace the docked region. On a height change the content scroll region
* is re-fenced; same-height repaints are pure absolute-addressed writes and
* can never scroll anything. With `caret` (dock-relative 1-based row and
* column) the real terminal cursor is left parked there, Claude Code-style.
*/
set(lines: string[], caret?: { row: number; col: number }): void {
if (!lines.length) {
this.release();
return;
}
const h = this.rows();
const shown = lines.slice(0, Math.max(1, h - 3)).map((l) => truncateVisible(l, this.cols() - 1));
const newH = shown.length;
let seq = SYNC_ON + HIDE;
if (this.parked) {
// Return to the content position first so the SAVE/RESTORE pairs below
// keep preserving it, not the old caret.
seq += RESTORE;
this.parked = false;
}
if (this.dockH === 0) {
// First activation: free the bottom rows by scrolling exactly as much
// as needed (preserves the banner above), then fence content into
// [1, h - newH]. DECSTBM homes the cursor; save/restore keeps it.
seq += SAVE + REGION_RESET + RESTORE;
seq += '\n'.repeat(newH) + `\x1b[${newH}A`;
seq += SAVE + `\x1b[1;${h - newH}r` + RESTORE;
} else if (newH !== this.dockH) {
// Height change while active (menu open/close): overlay mode. Re-fence
// and clear any rows freed by a shrink; never scroll content.
seq += SAVE + `\x1b[1;${h - newH}r`;
for (let r = h - Math.max(this.dockH, newH) + 1; r <= h - newH; r++) {
seq += `\x1b[${r};1H\x1b[2K`;
}
seq += RESTORE;
}
seq += SAVE;
for (let i = 0; i < newH; i++) {
seq += `\x1b[${h - newH + 1 + i};1H\x1b[2K${shown[i]!}`;
}
seq += RESTORE;
if (caret) {
const row = Math.min(h, h - newH + Math.max(1, caret.row));
const col = Math.max(1, Math.min(this.cols(), caret.col));
seq += SAVE + `\x1b[${row};${col}H`;
this.parked = true;
}
seq += SHOW + SYNC_OFF;
this.out.write(seq);
this.dockH = newH;
this.last = caret ? { lines, caret } : { lines };
}
/** Replay the last set() (e.g. after an animation overdrew part of the screen). */
repaint(): void {
if (this.last) this.set(this.last.lines, this.last.caret);
}
/** Rows available to content above the dock. */
contentRows(): number {
return Math.max(1, this.rows() - this.dockH);
}
/**
* Absolute-paint the whole content region (rows 1..contentRows) from the
* given lines — used by history scrolling to show any window of the session
* log. Does not touch the DECSC slot or the fence; callers repaint() the
* dock afterwards to restore the caret.
*/
paintContent(lines: string[]): void {
const rowsN = this.contentRows();
const shown = lines.slice(-rowsN).map((l) => truncateVisible(l, this.cols() - 1));
let seq = SYNC_ON + HIDE;
for (let r = 1; r <= rowsN; r++) {
seq += `\x1b[${r};1H\x1b[2K${shown[r - 1] ?? ''}`;
}
seq += SYNC_OFF;
this.out.write(seq);
}
/**
* Write a scrollback line above the dock. With the fence in place a plain
* write cannot touch the dock rows; if the cursor is parked at the caret,
* hop back to the content region for the write and re-park after.
*/
log(line: string): void {
if (!this.parked) {
this.out.write(line + '\n');
return;
}
this.out.write(SYNC_ON + RESTORE + line + '\n' + SAVE + SYNC_OFF);
// The slot now holds the advanced content position; the caret will be
// re-established by the next set() with a caret argument.
this.parked = false;
}
/** Drop the fence, clear the dock rows, and restore the cursor. */
release(): void {
if (!this.dockH) {
this.out.write(SHOW);
return;
}
const h = this.rows();
let seq = SYNC_ON;
if (this.parked) {
seq += RESTORE;
this.parked = false;
}
seq += SAVE + REGION_RESET + RESTORE;
seq += SAVE;
for (let r = h - this.dockH + 1; r <= h; r++) seq += `\x1b[${r};1H\x1b[2K`;
seq += RESTORE + SHOW + SYNC_OFF;
this.out.write(seq);
this.dockH = 0;
this.last = null;
}
}