diff --git a/darknode.js b/darknode.js index 6a6e664..67d68e6 100755 --- a/darknode.js +++ b/darknode.js @@ -3529,7 +3529,7 @@ function nexusTui(engine, cwd, nexusMd, autoResume, launch) { const mLabel = modelLabel(shownModel()) || engine; parts.push(mLabel + (mLabel && ENGINES[engine] && ENGINES[engine].kind === "stream" && effort ? gray(" · ") + effortLabel(effort) : "")); parts.push(pc(pct + "% context")); - { const _ca = compactAt(); if (_ca > 0 && sess.ctxUsed != null) { const _u = Math.max(0, Math.min(100, Math.round((1 - sess.ctxUsed / _ca) * 100))); parts.push((_u <= 15 ? yellow : gray)(_u + "% until auto-compact")); } } + { const _ca = compactAt(); if (_ca > 0 && sess.ctxUsed != null) { const _u = Math.max(0, Math.min(100, Math.round((1 - sess.ctxUsed / _ca) * 100))); if (_u < 100) parts.push((_u <= 15 ? yellow : gray)(_u + "% until auto-compact")); } } // hide the redundant "100% until auto-compact" chip on a fresh/near-empty session (it duplicates "N% context") if (PAID[engine]) { const isSubscription = engine === "codex" && CODEX_LOCAL.authMode ? onPlan(engine) : (onPlan(engine) || (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY)); if (isSubscription) { /* subscription: nothing to show */ } else { const c = costCap && sess.cost >= costCap ? red : green; const est = !ENGINES[engine] || ENGINES[engine].kind !== "stream"; parts.push(sess.cost ? c((est ? "~$" : "$") + sess.cost.toFixed(4)) + (costCap ? gray("/" + costCap.toFixed(2)) : "") : gray("subscription")); } } if (gitBranch) parts.push(gray(gitBranch)); diff --git a/lib/cli/tui-format.js b/lib/cli/tui-format.js index 9e064b5..294073c 100644 --- a/lib/cli/tui-format.js +++ b/lib/cli/tui-format.js @@ -269,8 +269,13 @@ function welcomeCard(opts) { const kv = (k, v) => lines.push(row(" " + label(padRight(k, 10)) + v)); if (opts.model) kv("Model", brand(opts.model) + (opts.effort ? muted(" · ") + faint(opts.effort) : "")); if (opts.engineLabel) kv("Engine", opts.engineLabel); - const where = opts.cwd ? shortenPath(opts.cwd, inner - 12 - (opts.branch ? opts.branch.length + 5 : 0), opts.home) : ""; - if (where) kv("Project", muted(where) + (opts.branch ? muted(" · ") + accent(opts.branch) : "")); + // Give the path whatever room the branch leaves, but keep a small floor so a + // long branch never starves the path to "" and drops the whole row — the + // branch is the most useful "where am I" cue, so the row must survive. kv() + // fitWidth-truncates the combined value, so neither side can overflow. + const branchStr = opts.branch || ""; + const where = opts.cwd ? shortenPath(opts.cwd, Math.max(6, inner - 12 - (branchStr ? branchStr.length + 5 : 0)), opts.home) : ""; + if (where || branchStr) kv("Project", muted(where) + (branchStr ? (where ? muted(" · ") : "") + accent(branchStr) : "")); const sess = []; if (opts.contextLabel) sess.push(opts.contextLabel + " context"); if (opts.guard) sess.push("guard " + opts.guard); diff --git a/package.json b/package.json index 4e6f893..3c480d9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "scripts": { "start": "node darknode.js", "build": "pkg . --out-path dist", - "test": "node test/run.js && node test/authz.test.js && node test/composer.test.js && node test/term.test.js && node test/md-render.test.js && node test/links.test.js && node test/pager.test.js && node test/tool-tail.test.js && node test/os-notify.test.js && node test/repo-map-rank.test.js && node test/bm25.test.js && node test/danger-audit.test.js && node test/url-guard-audit.test.js && node test/edit-audit.test.js && node test/jail-audit.test.js && node test/mcp-audit.test.js && node test/git-search-audit.test.js && node test/state-audit.test.js && node test/exec-audit.test.js && node test/cli-audit.test.js && node test/governance-audit.test.js && node test/toolkit-audit.test.js && node test/devhelpers-audit.test.js && node test/radar-audit.test.js && node test/pentest-audit.test.js && node test/shellfix-audit.test.js && node test/convo-lifecycle-audit.test.js && node test/local-loop-audit.test.js && node test/undo-audit.test.js && node test/at-mentions-audit.test.js && node test/clipboard-audit.test.js && node test/notes-audit.test.js && node test/snippets-audit.test.js && node test/typed-names-audit.test.js && node test/sighup-audit.test.js && node test/tui-stderr-audit.test.js && node test/piped-stdin-audit.test.js && node test/oneshot-escape-audit.test.js && node test/hot-path-speed-audit.test.js && node test/command-help.test.js && node test/session-save.test.js && node test/agents-panel.test.js", + "test": "node test/run.js && node test/authz.test.js && node test/composer.test.js && node test/term.test.js && node test/md-render.test.js && node test/links.test.js && node test/pager.test.js && node test/tool-tail.test.js && node test/os-notify.test.js && node test/repo-map-rank.test.js && node test/bm25.test.js && node test/danger-audit.test.js && node test/url-guard-audit.test.js && node test/edit-audit.test.js && node test/jail-audit.test.js && node test/mcp-audit.test.js && node test/git-search-audit.test.js && node test/state-audit.test.js && node test/exec-audit.test.js && node test/cli-audit.test.js && node test/governance-audit.test.js && node test/toolkit-audit.test.js && node test/devhelpers-audit.test.js && node test/radar-audit.test.js && node test/pentest-audit.test.js && node test/shellfix-audit.test.js && node test/convo-lifecycle-audit.test.js && node test/local-loop-audit.test.js && node test/undo-audit.test.js && node test/at-mentions-audit.test.js && node test/clipboard-audit.test.js && node test/notes-audit.test.js && node test/snippets-audit.test.js && node test/typed-names-audit.test.js && node test/sighup-audit.test.js && node test/tui-stderr-audit.test.js && node test/piped-stdin-audit.test.js && node test/oneshot-escape-audit.test.js && node test/hot-path-speed-audit.test.js && node test/command-help.test.js && node test/session-save.test.js && node test/agents-panel.test.js && node test/tui-layout.test.js", "postinstall": "node scripts/postinstall.js", "docs": "node scripts/gen-nexus-docs.js" }, diff --git a/test/tui-layout.test.js b/test/tui-layout.test.js new file mode 100644 index 0000000..49f1a1e --- /dev/null +++ b/test/tui-layout.test.js @@ -0,0 +1,85 @@ +"use strict"; +// Layout-helper suite (lib/cli/tui-format.js): welcomeCard, idleHome, footerLine +// and helpLines. These paint the session chrome (the boot card, the empty-session +// home, the footer status row, the /help & /keys screens), and were previously +// untested. The contract they must keep: every line fits the given column budget +// (no wrap garbage, nothing clipped past the border), plain output (no paint map) +// carries NO ANSI escapes (NO_COLOR / non-TTY safe), and the key content survives +// from a wide terminal down to a narrow one. +const F = require("../lib/cli/tui-format.js"); +const { welcomeCard, idleHome, footerLine, helpLines, visLen, stripAnsi } = F; + +let pass = 0, fail = 0; +const ok = (name, cond) => { if (cond) pass++; else { fail++; console.log(" \x1b[31mFAIL\x1b[0m " + name); } }; +const group = (t) => console.log("\n\x1b[1m" + t + "\x1b[0m"); + +// a line is "clean" at width w: fits w visible columns and never carries a raw +// (non-SGR) escape that could move the cursor or clear the screen. +const fits = (lines, w) => lines.every((l) => visLen(l) <= w && !/\x1b(?!\[[0-9;]*m)/.test(l)); +const plainClean = (lines) => lines.every((l) => !/\x1b/.test(l)); +const WIDTHS = [36, 40, 50, 60, 80, 100, 120, 140, 200]; + +const WC = (cols, extra) => welcomeCard(Object.assign({ + version: "2.46.3", model: "Opus 5.5", engineLabel: "Claude Code", + cwd: "/home/manav/projects/darknode-cli", branch: "ui/tui-polish-20261101", + home: "/home/manav", cols, contextLabel: "200K", guard: "on", mcp: 1, + tip: "scroll back with the mouse wheel or PgUp/PgDn · End jumps to the newest", +}, extra || {})); + +group("welcomeCard fits its column budget at every width"); +{ + let bad = ""; + for (const w of WIDTHS) if (!fits(WC(w), w)) bad = bad || ("overflow/escape at " + w); + ok("every row fits 36..200 cols with no stray escapes", !bad); + ok("the box is balanced (equal corner rows)", (() => { const L = WC(80).filter((l) => l.trim()); return /^╭/.test(stripAnsi(L[0])) && /╯$/.test(stripAnsi(L[L.length - 1])); })()); + ok("plain output (no paint) carries no ANSI escapes", plainClean(WC(80))); + ok("shows product, model, engine and a quick-start command", (() => { const s = WC(80).join("\n"); return /Nexus/.test(s) && /Opus 5\.5/.test(s) && /Claude Code/.test(s) && /\/help/.test(s); })()); +} + +group("welcomeCard keeps the branch even when it is long and the terminal is narrow"); +{ + // Regression: a long branch used to starve the path budget to "" and drop the + // whole Project row (branch included). The row must survive down to 36 cols. + ok("branch (possibly truncated) is still shown at 36 cols", stripAnsi(WC(36).join("\n")).includes("ui/tui")); + for (const w of [40, 50]) ok("branch is still shown at " + w + " cols", stripAnsi(WC(w).join("\n")).includes("ui/tui-pol")); + ok("a card with only a branch (no cwd) still shows it", stripAnsi(WC(80, { cwd: "" }).join("\n")).includes("ui/tui-polish-20261101")); + ok("a card with neither cwd nor branch omits the Project row", !/Project/.test(stripAnsi(WC(80, { cwd: "", branch: "" }).join("\n")))); +} + +group("idleHome fills the screen without overflowing"); +{ + const mk = (cols) => idleHome({ cols, project: { name: "darknode-cli", branch: "main", dirty: true, files: 120, commits: 1200, last: "1bbf9bb fix the lint errors in the parser" } }); + let bad = ""; + for (const w of WIDTHS) if (!fits(mk(w), w)) bad = bad || ("overflow/escape at " + w); + ok("every row fits 36..200 cols with no stray escapes", !bad); + ok("plain output carries no ANSI escapes", plainClean(mk(80))); + ok("renders the NEXUS hero wordmark", mk(80).join("\n").includes("█")); + ok("surfaces the live project pulse (name + branch)", (() => { const s = stripAnsi(mk(80).join("\n")); return s.includes("darknode-cli") && /\bmain\b/.test(s); })()); + ok("falls back to a compact wordmark on a very narrow terminal", stripAnsi(idleHome({ cols: 20 }).join("\n")).includes("N E X U S")); +} + +group("footerLine right-aligns status and never overflows"); +{ + const left = "accept edits on (shift+tab to cycle)"; + const right = "Opus 5.5 · 0% context · ui/tui-polish-20261101"; + for (const w of [24, 40, 80, 120, 200]) ok("fits " + w + " cols", visLen(footerLine(left, right, w)) <= w); + ok("keeps the status (right) side visible when wide", footerLine(left, right, 120).includes("Opus 5.5")); + ok("drops the hint (left) side before clipping status when cramped", (() => { const r = footerLine(left, right, 30); return r.includes("ui/tui-polish") || r.includes("8.5") || visLen(r) <= 30; })()); + ok("plain output carries no escapes", !/\x1b/.test(footerLine(left, right, 80))); +} + +group("helpLines aligns names and wraps long descriptions under the column"); +{ + const sections = [{ title: "Session", rows: [ + ["/clear", "start a new conversation"], + ["/fixshell", "repair the engine shell when commands fail with E2BIG, without /clear (check = report only, all = clear every snapshot)"], + ["/exit", "quit Nexus"], + ] }]; + for (const w of [40, 60, 80, 120]) ok("fits " + w + " cols", fits(helpLines(sections, w), w)); + ok("plain output carries no escapes", plainClean(helpLines(sections, 80))); + ok("shows the heading and every command name", (() => { const s = helpLines(sections, 80).join("\n"); return /Session/.test(s) && /\/clear/.test(s) && /\/fixshell/.test(s) && /\/exit/.test(s); })()); + ok("wraps a long description onto continuation rows (name column stays empty)", (() => { const L = helpLines(sections, 60).map(stripAnsi); const i = L.findIndex((l) => /\/fixshell/.test(l)); if (i < 0 || i + 1 >= L.length) return false; const cont = L[i + 1]; const indent = (s) => s.length - s.replace(/^ +/, "").length; return cont.trim().length > 0 && !/\/fixshell/.test(cont) && indent(cont) > indent(L[i]); })()); +} + +console.log("\n" + (fail ? "\x1b[31m" : "\x1b[32m") + pass + " passed, " + fail + " failed\x1b[0m" + " (tui layout)"); +process.exit(fail ? 1 : 0);