Download lib/tui.mjs from teddyoas/Asnip: direct link, hf CLI and curl.
- Browser
- Download file 5.28 kB
-
https://huggingface.co/teddyoas/Asnip/resolve/main/lib/tui.mjs
- Command line
-
hf download hf://teddyoas/Asnip/lib/tui.mjs
-
curl -L -o tui.mjs https://huggingface.co/teddyoas/Asnip/resolve/main/lib/tui.mjs
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"); | |
| } | |
| } | |