Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/core/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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]
*/
Expand Down Expand Up @@ -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]
Expand All @@ -125,6 +169,7 @@ export async function runLoopTui({
output = process.stdout,
once = false,
clearScreen = true,
env = process.env,
openTargetImpl = openTarget,
launchTerminalCommandImpl = launchTerminalCommand
} = {}) {
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
66 changes: 63 additions & 3 deletions test/tui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof renderTuiHome>[0]} */
const snapshot = {
stateDir: ".loop",
agent: "codex",
selectedRunId: "run-1",
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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"
Expand Down