117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
/**
|
|
* Lightweight line-based terminal renderer for the TUI. Renders the
|
|
* `AppStateRest` transcript, input line, overlay, and status into an array of
|
|
* plain-text + ANSI escaped lines. No external ncurses dependency — outputs
|
|
* raw ANSI sequences that work in any terminal. Conceptually mirrors
|
|
* `apps/interfaces/tui/src/view/**` but simplified to line output.
|
|
*/
|
|
import { Roles } from "@zesdex/domain";
|
|
import type { AppStateRest } from "./state.ts";
|
|
import { Overlay } from "./state.ts";
|
|
|
|
const roleLabel: Record<string, string> = {
|
|
[Roles.User]: "you",
|
|
[Roles.Assistant]: "zesdex",
|
|
[Roles.System]: "system",
|
|
[Roles.Tool]: "tool",
|
|
};
|
|
|
|
/** Render the full TUI frame into an array of lines (with ANSI colours). */
|
|
export function renderFrame(state: AppStateRest, height: number, width: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
// Header bar.
|
|
lines.push(renderHeader(state, width));
|
|
|
|
// Transcript pane.
|
|
const transcriptLines = renderTranscript(state, width);
|
|
lines.push(...transcriptLines);
|
|
|
|
// Padding to fill remaining height.
|
|
const used = lines.length;
|
|
for (let i = used; i < height - 3; i++) {
|
|
lines.push("");
|
|
}
|
|
|
|
// Overlay content (if any) replaces the input area.
|
|
if (state.misc.overlay !== Overlay.None) {
|
|
lines.push(renderOverlay(state, width));
|
|
} else {
|
|
lines.push(renderInput(state, width));
|
|
}
|
|
|
|
// Status/footer bar.
|
|
lines.push(renderStatus(state, width));
|
|
|
|
return lines.slice(0, height);
|
|
}
|
|
|
|
function renderHeader(_state: AppStateRest, width: number): string {
|
|
const title = " zesdex ";
|
|
const pad = Math.max(0, width - title.length);
|
|
return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`;
|
|
}
|
|
|
|
function renderTranscript(state: AppStateRest, width: number): string[] {
|
|
const out: string[] = [];
|
|
const messages = state.transcriptCache.messages;
|
|
// Honour scroll offset from the bottom like a typical chat TUI.
|
|
const start = Math.max(0, messages.length - 30 - state.scroll.offset);
|
|
for (let i = start; i < messages.length; i++) {
|
|
const m = messages[i];
|
|
if (!m) continue;
|
|
const label = (roleLabel[m.role] ?? m.role).padEnd(8);
|
|
const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m";
|
|
out.push(`${color}${label}\x1b[0m ${m.content}`);
|
|
if (m.reasoning) {
|
|
out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`);
|
|
}
|
|
}
|
|
void width;
|
|
return out;
|
|
}
|
|
|
|
function renderInput(state: AppStateRest, width: number): string {
|
|
const prompt = "> ";
|
|
const shown = state.input.buffer.length > width - prompt.length
|
|
? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length))
|
|
: state.input.buffer;
|
|
let line = `\x1b[1m${prompt}\x1b[0m${shown}`;
|
|
// Append autocomplete hint if visible.
|
|
if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) {
|
|
const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx];
|
|
line += ` \x1b[2m[tab: ${cand}]\x1b[0m`;
|
|
}
|
|
// Toasts.
|
|
for (const t of state.misc.toasts.slice(-2)) {
|
|
line += ` \x1b[3m(${t.message})\x1b[0m`;
|
|
}
|
|
return line.length > width ? line.slice(0, width) : line;
|
|
}
|
|
|
|
function renderOverlay(state: AppStateRest, width: number): string {
|
|
const name = state.misc.overlay;
|
|
if (name === "diff") {
|
|
const diffLines = state.misc.diffContent.split("\n");
|
|
const from = state.misc.diffScroll;
|
|
return diffLines.slice(from, from + 10).join("\n ");
|
|
}
|
|
if (name === "quit_confirm") {
|
|
return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m";
|
|
}
|
|
if (name === "help") {
|
|
return state.helpText;
|
|
}
|
|
void width;
|
|
return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`;
|
|
}
|
|
|
|
function renderStatus(state: AppStateRest, width: number): string {
|
|
const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`;
|
|
const right = state.turnInFlightFlag.value
|
|
? " \x1b[5m● running\x1b[0m"
|
|
: " \x1b[2m● idle\x1b[0m";
|
|
const combined = `${left}${right}`;
|
|
return combined.length > width ? combined.slice(0, width) : combined;
|
|
}
|