Asnip / lib /tui.mjs
teddyoas's picture
Duplicate from teddyoas/PHANTOM-v2
20f108c
Raw History Blame Contribute Delete
5.28 kB
// TUI — Incremental terminal UI
// Logo drawn once at top. Messages append with \n, terminal scrolls naturally.
// Input bar redrawn at bottom after each log. Full redraw only on resize.
// No repeated logo frames in scrollback history.
import { renderLogo } from "./visual.mjs";
const R = "\x1b[0m";
const CSI = "\x1b[";
const home = CSI + "H";
const cls = CSI + "2J";
const hide = CSI + "?25l";
const show = CSI + "?25h";
function c(name) {
const cl = { black:30, red:31, green:32, yellow:33, blue:34, magenta:35, cyan:36, white:37, dim:2, fg:37 };
return `${CSI}${cl[name] ?? 37}m`;
}
function dim(s) { return `${c("dim")}${s}${R}`; }
export class TUI {
constructor(opts = {}) {
this.active = false;
this._buf = [];
this._maxBuf = 500;
this._inputText = "";
this._toolCount = 0;
this._onExit = opts.onExit || (() => {});
this._rows = process.stdout.rows || 24;
this._cols = process.stdout.columns || 80;
this._resizeHandler = null;
this._convStart = 0; // 1-based row where conversation starts
this._convMax = 0; // how many conversation lines fit on screen
this._onResize = () => {
const newRows = process.stdout.rows || 24;
const newCols = process.stdout.columns || 80;
if (newRows === this._rows && newCols === this._cols) return;
this._rows = newRows;
this._cols = newCols;
if (this.active) this._fullRedraw();
};
}
enter() {
if (this.active) { this.exit(); }
process.stdout.write(home + cls + hide);
// ── Logo (drawn once at top, scrolls away naturally) ──
const logo = renderLogo({ tools: this._toolCount || 0 });
const logoH = logo.split("\n").length;
process.stdout.write(logo + "\n");
process.stdout.write(dim("\u2500".repeat(this._cols)) + "\n");
// ── Conversation area ──
this._convStart = logoH + 2; // 1-based row right after separator
this._convMax = this._rows - logoH - 3;
this._msgCount = 0;
// Fill with blank lines then input bar
for (let i = 0; i < this._convMax; i++) process.stdout.write("\n");
process.stdout.write(dim("\u2500".repeat(this._cols)) + "\n");
process.stdout.write(c("green") + "\u25b8 " + R);
// Move cursor to conversation start for first message
process.stdout.write(CSI + this._convStart + ";1H");
this.active = true;
if (typeof process.stdout.on === "function" && !this._resizeHandler) {
this._resizeHandler = this._onResize.bind(this);
process.stdout.on("resize", this._resizeHandler);
}
}
exit() {
if (!this.active) return;
if (this._resizeHandler) {
try { process.stdout.removeListener("resize", this._resizeHandler); } catch {}
this._resizeHandler = null;
}
this._onExit();
process.stdout.write(show);
this.active = false;
}
log(text) {
if (!this.active) { process.stdout.write(text + "\n"); return; }
this._buf.push(text);
if (this._buf.length > this._maxBuf) this._buf.shift();
// Position cursor at the next conversation row and write
const row = this._convStart + this._msgCount;
if (row <= this._rows) {
process.stdout.write(CSI + row + ";1H" + CSI + "2K");
}
process.stdout.write(text + "\n");
this._msgCount++;
// Redraw input bar (may have been pushed up by terminal scroll)
this._redrawInput();
}
_fullRedraw() {
if (!this.active) return;
const rows = this._rows;
const cols = this._cols;
let out = home + cls;
const logo = renderLogo({ tools: this._toolCount || 0 });
const logoH = logo.split("\n").length;
out += logo + "\n";
out += dim("\u2500".repeat(cols)) + "\n";
this._convStart = logoH + 2;
this._convMax = rows - logoH - 3;
// Show last convMax lines from buffer
const visible = [];
let lineCount = 0;
for (let i = this._buf.length - 1; i >= 0 && lineCount < this._convMax; i--) {
const entry = this._buf[i];
const lc = entry ? Math.max(1, Math.ceil(entry.replace(/\x1b\[[0-9;]*[mK]/g, '').length / Math.max(cols, 1))) : 1;
if (lineCount + lc <= this._convMax) {
visible.unshift(entry);
lineCount += lc;
} else break;
}
for (const line of visible) out += line + "\n";
const remaining = this._convMax - lineCount;
for (let i = 0; i < remaining; i++) out += "\n";
out += dim("\u2500".repeat(cols)) + "\n";
const visibleLen = this._inputText.replace(/\x1b\[[0-9;]*[mK]/g, '').length;
out += c("green") + "\u25b8 " + R + this._inputText;
process.stdout.write(out);
process.stdout.write(CSI + rows + ";" + (3 + visibleLen) + "H");
}
setInput(text) {
this._inputText = text || "";
this._redrawInput();
}
setToolCount(n) { this._toolCount = n; }
_redrawInput() {
const rows = this._rows;
const cols = this._cols;
const visibleLen = this._inputText.replace(/\x1b\[[0-9;]*[mK]/g, '').length;
process.stdout.write(CSI + (rows - 1) + ";1H" + CSI + "2K");
process.stdout.write(dim("\u2500".repeat(cols)));
process.stdout.write(CSI + rows + ";1H" + CSI + "2K");
process.stdout.write(c("green") + "\u25b8 " + R + this._inputText);
process.stdout.write(CSI + rows + ";" + (3 + visibleLen) + "H");
}
}