From ae6df64d1a19977f0c88820c0aeabf3c8926de09 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Fri, 31 Jul 2026 15:31:34 +0000 Subject: [PATCH] refactor(eve): stop hand-maintaining what the dev TUI already knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places kept a copy of something the code could derive. `MAX_VISIBLE_SUGGESTIONS` was a hand-maintained mirror of the command registry's length, with a comment instructing the next author to bump it — and it had already drifted to 11 against 10 entries. Worse, `command-typeahead.test.ts` asserted the bare-slash row count as `Math.min(PROMPT_COMMANDS.length, 10)`, which matches today only because `min(10, 11)` and `min(10, 10)` agree: adding an 11th command and bumping the constant as instructed would have failed that test. The constant now reads the registry, so the "never scroll a real command out of view" invariant holds mechanically, and both test expectations reference the registry instead of a literal. In `wrapVisibleLine`, the #1442 linear-time rewrite spelled its "consume one unit" step twice — once inside a single-use `advanceTo` closure, once in the whitespace-collapse loop. One `consume()` closure serves both walks, keeping the single forward pass the rewrite exists for. The empty-line early return is also gone: with no units, the general path already emits exactly one empty row, which a new test now pins. `ansiPattern`, `ansiPrefixPattern`, and `codePointWidth` lose their `export` — nothing outside the module referenced them. Signed-off-by: Chad Hietala --- .changeset/tui-derived-constants.md | 5 +++ .../src/cli/dev/tui/command-typeahead.test.ts | 4 +-- .../eve/src/cli/dev/tui/command-typeahead.ts | 8 ++--- packages/eve/src/cli/ui/terminal-text.test.ts | 5 +++ packages/eve/src/cli/ui/terminal-text.ts | 31 ++++++++----------- 5 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 .changeset/tui-derived-constants.md diff --git a/.changeset/tui-derived-constants.md b/.changeset/tui-derived-constants.md new file mode 100644 index 000000000..577f1d834 --- /dev/null +++ b/.changeset/tui-derived-constants.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Derive the dev TUI's slash-command suggestion window from the command registry instead of a hand-maintained constant, and collapse the duplicated cursor step in the terminal line-wrap loop. diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts index 3e69ea956..20c9dda88 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts @@ -172,7 +172,7 @@ describe("renderCommandSuggestions", () => { const many = Array.from({ length: 14 }, (_, index) => spec(`command-${index}`)); const state = { ...typeaheadFor(many, "/"), selectedIndex: 13 }; const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi); - expect(rows).toHaveLength(11); + expect(rows).toHaveLength(PROMPT_COMMANDS.length); expect(rows.some((row) => row.includes("command-13"))).toBe(true); expect(rows.some((row) => row.includes("command-0"))).toBe(false); expect(rows.some((row) => row.includes("commands, showing"))).toBe(false); @@ -181,7 +181,7 @@ describe("renderCommandSuggestions", () => { it("shows the first window of the command registry on a bare slash", () => { const state = typeaheadFor(PROMPT_COMMANDS, "/"); const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi); - expect(rows).toHaveLength(Math.min(PROMPT_COMMANDS.length, 10)); + expect(rows).toHaveLength(PROMPT_COMMANDS.length); expect(rows[0]).toContain("/help"); }); diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.ts b/packages/eve/src/cli/dev/tui/command-typeahead.ts index a90171653..d443e66fb 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.ts @@ -7,17 +7,17 @@ */ import type { PromptCommandSpec } from "./prompt-commands.js"; +import { PROMPT_COMMANDS } from "./prompt-commands.js"; import { sliceVisible, visibleLength } from "#cli/ui/terminal-text.js"; import type { Theme } from "./theme.js"; import { renderCursorRow } from "#setup/cli/option-row.js"; /** * The typeahead keeps the list scannable; extra matches window around the - * cursor. Sized to hold the full command registry so a bare `/` never scrolls - * a command (e.g. `/exit`) out of view — windowing is for longer future lists. - * Keep this >= the number of entries in `PROMPT_COMMANDS`. + * cursor. Derived from the registry so a bare `/` never scrolls a command + * (e.g. `/exit`) out of view — windowing is for longer future lists. */ -const MAX_VISIBLE_SUGGESTIONS = 11; +const MAX_VISIBLE_SUGGESTIONS = PROMPT_COMMANDS.length; export interface CommandTypeaheadState { /** The prompt text the matches were derived from. */ diff --git a/packages/eve/src/cli/ui/terminal-text.test.ts b/packages/eve/src/cli/ui/terminal-text.test.ts index 12f938f94..b20a7b2e4 100644 --- a/packages/eve/src/cli/ui/terminal-text.test.ts +++ b/packages/eve/src/cli/ui/terminal-text.test.ts @@ -61,6 +61,11 @@ describe("editable input geometry", () => { expect(wrapVisibleLine("\x1b[31m🇺🇸\x1b[0m", 1)).toEqual(["\x1b[31m🇺🇸\x1b[0m"]); }); + it("keeps an empty line as one empty row", () => { + expect(wrapVisibleLine("", 10)).toEqual([""]); + expect(wrapVisibleLine("", 0)).toEqual([""]); + }); + it("word-wraps on the last space that fits and collapses the break whitespace", () => { expect(wrapVisibleLine("alpha beta gamma", 10)).toEqual(["alpha beta", "gamma"]); expect(wrapVisibleLine("alpha beta gamma", 6)).toEqual(["alpha", "beta", "gamma"]); diff --git a/packages/eve/src/cli/ui/terminal-text.ts b/packages/eve/src/cli/ui/terminal-text.ts index 9acdfb12d..6e9a87cfd 100644 --- a/packages/eve/src/cli/ui/terminal-text.ts +++ b/packages/eve/src/cli/ui/terminal-text.ts @@ -2,8 +2,8 @@ import { graphemes } from "#shared/text-boundaries.js"; const ansiEscape = String.fromCharCode(27); -export const ansiPattern = new RegExp(`${ansiEscape}\\[[0-?]*[ -/]*[@-~]`, "g"); -export const ansiPrefixPattern = new RegExp(`^${ansiEscape}\\[[0-?]*[ -/]*[@-~]`); +const ansiPattern = new RegExp(`${ansiEscape}\\[[0-?]*[ -/]*[@-~]`, "g"); +const ansiPrefixPattern = new RegExp(`^${ansiEscape}\\[[0-?]*[ -/]*[@-~]`); const emojiPresentationPattern = /\p{Emoji_Presentation}/u; const extendedPictographicPattern = /\p{Extended_Pictographic}/u; const keycapPattern = /^[#*0-9]\u{fe0f}?\u{20e3}$/u; @@ -182,10 +182,6 @@ export function wrapVisibleLine(line: string, width: number): string[] { return [line]; } - if (line.length === 0) { - return [""]; - } - const units = terminalTextUnits(line); let remainingWidth = 0; for (const unit of units) remainingWidth += unit.width; @@ -194,25 +190,24 @@ export function wrapVisibleLine(line: string, width: number): string[] { let unitIndex = 0; let charOffset = 0; - const advanceTo = (targetOffset: number): void => { - while (charOffset < targetOffset && unitIndex < units.length) { - const unit = units[unitIndex]!; - charOffset += unit.text.length; - remainingWidth -= unit.width; - unitIndex += 1; - } + // The cursor moves one unit at a time: past the row just emitted, then past + // the whitespace the break consumed. + const consume = (): void => { + const unit = units[unitIndex]!; + charOffset += unit.text.length; + remainingWidth -= unit.width; + unitIndex += 1; }; while (remainingWidth > width) { const breakAt = findVisibleBreakPoint(units, unitIndex, width); lines.push(line.slice(charOffset, charOffset + breakAt).trimEnd()); - advanceTo(charOffset + breakAt); + const rowEnd = charOffset + breakAt; + while (charOffset < rowEnd && unitIndex < units.length) consume(); while (unitIndex < units.length) { const unit = units[unitIndex]!; if (unit.ansi || unit.text.trimStart().length > 0) break; - charOffset += unit.text.length; - remainingWidth -= unit.width; - unitIndex += 1; + consume(); } } @@ -247,7 +242,7 @@ function findVisibleBreakPoint( return offset; } -export function codePointWidth(codePoint: number): number { +function codePointWidth(codePoint: number): number { if (codePoint === 0x09) { return 4; }