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; }