File size: 5,279 Bytes
20f108c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | // 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");
}
}
|