zcode-api / src /tui /log-pane.ts
bolikoto's picture
Deploy minimal Docker Space
67d18ac verified
Raw History Blame Contribute Delete
2.81 kB
/**
* The log pane: a bounded ring of leveled log lines with tail-following and
* scrollback, mirroring the Android app's Logs card (LazyColumn that sticks
* to the newest line). Pure state — the frame renderer and terminal glue
* read it, tests drive it directly.
*/
import { stripAnsi } from "./width.js";
export type LogLevel = "info" | "warn" | "error";
export interface LogLine {
readonly seq: number;
readonly level: LogLevel;
readonly text: string;
}
export interface LogView {
/** The lines to draw, oldest first, at most `height` of them. */
readonly lines: readonly LogLine[];
/** Total lines retained (for the "Logs (N)" header). */
readonly total: number;
/** Lines hidden BELOW the viewport toward the tail (0 while following). */
readonly fromBottom: number;
}
export class LogPane {
private lines: LogLine[] = [];
private nextSeq = 0;
/** Lines hidden below the viewport. 0 = follow the tail. */
private offset = 0;
constructor(private readonly capacity = 2000) {}
/**
* Append a log record. ANSI escape sequences are stripped (proxy logs carry
* color codes; the pane applies its own) and embedded newlines split into
* separate rows so multi-line records scroll naturally.
*/
push(text: string, level: LogLevel = "info"): void {
const clean = stripAnsi(String(text)).replace(/\r/g, "");
const parts = clean.split("\n");
if (parts.length > 1 && parts[parts.length - 1] === "") parts.pop();
for (const part of parts) {
this.lines.push({ seq: this.nextSeq++, level, text: part });
}
if (this.lines.length > this.capacity) {
this.lines.splice(0, this.lines.length - this.capacity);
}
}
get count(): number {
return this.lines.length;
}
/** True when the viewport is pinned to the newest lines. */
get following(): boolean {
return this.offset === 0;
}
scrollUp(n: number): void {
this.offset = Math.min(this.lines.length, this.offset + Math.max(0, n));
}
scrollDown(n: number): void {
this.offset = Math.max(0, this.offset - Math.max(0, n));
}
/** Jump back to the tail (re-enable following). */
followBottom(): void {
this.offset = 0;
}
clear(): void {
this.lines = [];
this.offset = 0;
}
/** Slice of lines currently visible in a viewport `height` rows tall. */
view(height: number): LogView {
const rows = Math.max(1, height);
// At max scrollback (offset === length) the viewport pins to the oldest
// rows instead of going empty.
const end = Math.max(Math.min(rows, this.lines.length), this.lines.length - this.offset);
const start = Math.max(0, end - rows);
return {
lines: this.lines.slice(start, end),
total: this.lines.length,
fromBottom: this.lines.length - end,
};
}
}