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
5 changes: 5 additions & 0 deletions .changeset/tui-derived-constants.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/eve/src/cli/dev/tui/command-typeahead.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
});

Expand Down
8 changes: 4 additions & 4 deletions packages/eve/src/cli/dev/tui/command-typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions packages/eve/src/cli/ui/terminal-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
31 changes: 13 additions & 18 deletions packages/eve/src/cli/ui/terminal-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
}

Expand Down Expand Up @@ -247,7 +242,7 @@ function findVisibleBreakPoint(
return offset;
}

export function codePointWidth(codePoint: number): number {
function codePointWidth(codePoint: number): number {
if (codePoint === 0x09) {
return 4;
}
Expand Down
Loading