From 462ec8ede257bf14d924fef342a6d7bacb7d9b84 Mon Sep 17 00:00:00 2001 From: rlaope Date: Mon, 15 Jun 2026 15:39:11 +0900 Subject: [PATCH] Add red TUI init logo Signed-off-by: rlaope --- src/core/tui.js | 52 ++++++++++++++++++++++++++++++++++++++ test/tui.test.js | 66 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/core/tui.js b/src/core/tui.js index 4a3cb2e..57daac8 100644 --- a/src/core/tui.js +++ b/src/core/tui.js @@ -21,6 +21,23 @@ import { loopRunCommand } from "./terminal-launcher.js"; +const RED = "\x1b[38;5;167m"; +const DIM_RED = "\x1b[38;5;88m"; +const RESET = "\x1b[0m"; +const LOOP_LOGO_LINES = [ + " _ ___ ___ ____ ", + "| | / _ \\ / _ \\| _ \\", + "| | | | | | | | | |_) |", + "| |___| |_| | |_| | __/", + "|_____|\\___/ \\___/|_|", + " .----->----.", + " .-' '-.", + " .' plan act '.", + " \\ verify stop /", + " '-. .-'", + " '----<----'" +]; + /** * @param {{ argCount: number, stdinTTY: boolean, stdoutTTY: boolean }} input */ @@ -39,6 +56,32 @@ function truncate(value, maxLength) { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; } +/** + * @param {{ color?: boolean }} [options] + */ +function renderTuiLogo({ color = false } = {}) { + if (!color) { + return LOOP_LOGO_LINES.join("\n"); + } + return LOOP_LOGO_LINES.map((line, index) => { + const tone = index < 5 ? RED : DIM_RED; + return `${tone}${line}${RESET}`; + }).join("\n"); +} + +/** + * @param {{ isTTY?: boolean, env?: NodeJS.ProcessEnv }} input + */ +function shouldUseTuiColor({ isTTY = false, env = process.env }) { + if (!isTTY) { + return false; + } + if (env.NO_COLOR !== undefined || env.FORCE_COLOR === "0" || env.TERM === "dumb") { + return false; + } + return true; +} + /** * @param {{ stateDir?: string, selectedRunId?: string | null, agent?: "codex" | "claudecode" }} [options] */ @@ -115,6 +158,7 @@ function writeStatus(output, message) { * output?: NodeJS.WritableStream & { isTTY?: boolean }, * once?: boolean, * clearScreen?: boolean, + * env?: NodeJS.ProcessEnv, * openTargetImpl?: typeof openTarget, * launchTerminalCommandImpl?: typeof launchTerminalCommand * }} [options] @@ -125,6 +169,7 @@ export async function runLoopTui({ output = process.stdout, once = false, clearScreen = true, + env = process.env, openTargetImpl = openTarget, launchTerminalCommandImpl = launchTerminalCommand } = {}) { @@ -133,6 +178,7 @@ export async function runLoopTui({ } let selectedRunId = /** @type {string | null} */ (null); let agent = /** @type {"codex" | "claudecode"} */ ("codex"); + let showLogo = true; const rl = createInterface({ input, output }); try { while (true) { @@ -141,7 +187,13 @@ export async function runLoopTui({ if (clearScreen) { output.write("\x1Bc"); } + if (showLogo) { + output.write(`${renderTuiLogo({ + color: shouldUseTuiColor({ isTTY: output.isTTY, env }) + })}\n\n`); + } output.write(renderTuiHome(snapshot)); + showLogo = false; if (once) { return; } diff --git a/test/tui.test.js b/test/tui.test.js index 927a163..7d5bcdb 100644 --- a/test/tui.test.js +++ b/test/tui.test.js @@ -34,7 +34,8 @@ test("no-arg dispatch opens TUI only for interactive terminals", () => { }); test("TUI home render shows runs, wiki, graph, and commands", () => { - const html = renderTuiHome({ + /** @type {Parameters[0]} */ + const snapshot = { stateDir: ".loop", agent: "codex", selectedRunId: "run-1", @@ -100,8 +101,10 @@ test("TUI home render shows runs, wiki, graph, and commands", () => { }], edges: [] } - }); + }; + const html = renderTuiHome(snapshot); + assert.doesNotMatch(html, /\.----->----\./); assert.match(html, /Loop Agent Console/); assert.match(html, /Build a darkwear exhibit/); assert.match(html, /Wiki: 1 notes/); @@ -130,13 +133,70 @@ test("TUI one-shot mode renders local state without waiting for input", async () input, output, once: true, - clearScreen: false + clearScreen: false, + env: { FORCE_COLOR: "1" } }); + assert.match(text, /\x1b\[38;5;167m/); + assert.match(text, /\.----->----\./); assert.match(text, /Loop Agent Console/); assert.match(text, /Render TUI objective/); }); +test("TUI init logo honors no-color terminal preferences", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-tui-no-color-")); + const input = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + const output = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + input.isTTY = true; + output.isTTY = true; + let text = ""; + output.on("data", (chunk) => { + text += String(chunk); + }); + + await runLoopTui({ + stateDir, + input, + output, + once: true, + clearScreen: false, + env: { NO_COLOR: "1" } + }); + + assert.match(text, /\.----->----\./); + assert.doesNotMatch(text, /\x1b\[/); +}); + +test("TUI init logo appears only on the startup render", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-tui-logo-once-")); + const input = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + const output = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + input.isTTY = true; + output.isTTY = true; + let text = ""; + let promptCount = 0; + output.on("data", (chunk) => { + const value = String(chunk); + text += value; + if (!value.includes("loop> ")) { + return; + } + promptCount += 1; + input.write(promptCount === 1 ? "refresh\n" : "q\n"); + }); + + await runLoopTui({ + stateDir, + input, + output, + clearScreen: false, + env: { FORCE_COLOR: "1" } + }); + + assert.equal((text.match(/\.----->----\./g) ?? []).length, 1); + assert.equal((text.match(/Loop Agent Console/g) ?? []).length, 2); +}); + test("CLI no-arg non-TTY prints TUI guidance", () => { const result = spawnSync(process.execPath, ["bin/loop.js"], { encoding: "utf8"