diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 07a4bd22..bbe3e7e5 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -5,6 +5,7 @@ import { getCapabilities, type SelectItem, SelectList, + type SelectListLayoutOptions, type SettingItem, SettingsList, Spacer, @@ -13,6 +14,11 @@ import { import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", minimal: "Very brief reasoning (~1k tokens)", @@ -100,7 +106,12 @@ class SelectSubmenu extends Container { this.addChild(new Spacer(1)); // Select list - this.selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme()); + this.selectList = new SelectList( + options, + Math.min(options.length, 10), + getSelectListTheme(), + SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, + ); // Pre-select current value const currentIndex = options.findIndex((o) => o.value === currentValue); diff --git a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts index ea69ff25..9163b017 100644 --- a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts @@ -1,7 +1,12 @@ -import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui"; import { getSelectListTheme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +const SHOW_IMAGES_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + /** * Component that renders a show images selector with borders */ @@ -20,7 +25,7 @@ export class ShowImagesSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(items, 5, getSelectListTheme()); + this.selectList = new SelectList(items, 5, getSelectListTheme(), SHOW_IMAGES_SELECT_LIST_LAYOUT); // Preselect current value this.selectList.setSelectedIndex(currentValue ? 0 : 1); diff --git a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts index f7ffc017..7a82caa6 100644 --- a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts @@ -1,7 +1,12 @@ -import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui"; import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + /** * Component that renders a theme selector */ @@ -30,7 +35,7 @@ export class ThemeSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(themeItems, 10, getSelectListTheme()); + this.selectList = new SelectList(themeItems, 10, getSelectListTheme(), THEME_SELECT_LIST_LAYOUT); // Preselect current theme const currentIndex = themes.indexOf(currentTheme); diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index 069de5fb..9a01ef1e 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -1,8 +1,13 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui"; import { getSelectListTheme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + const LEVEL_DESCRIPTIONS: Record = { off: "No reasoning", minimal: "Very brief reasoning (~1k tokens)", @@ -36,7 +41,12 @@ export class ThinkingSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme()); + this.selectList = new SelectList( + thinkingLevels, + thinkingLevels.length, + getSelectListTheme(), + THINKING_SELECT_LIST_LAYOUT, + ); // Preselect current level const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 73185a89..86e76608 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -5,7 +5,7 @@ import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js"; -import { SelectList, type SelectListTheme } from "./select-list.js"; +import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.js"; const baseSegmenter = getSegmenter(); @@ -207,6 +207,11 @@ export interface EditorOptions { autocompleteMaxVisible?: number; } +const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], @@ -2031,6 +2036,14 @@ export class Editor implements Component, Focusable { return firstPrefixIndex; } + private createAutocompleteList( + prefix: string, + items: Array<{ value: string; label: string; description?: string }>, + ): SelectList { + const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined; + return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout); + } + private tryTriggerAutocomplete(explicitTab: boolean = false): void { if (!this.autocompleteProvider) return; @@ -2053,7 +2066,7 @@ export class Editor implements Component, Focusable { if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); // If typed prefix exactly matches one of the suggestions, select that item const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); @@ -2129,7 +2142,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ } this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); // If typed prefix exactly matches one of the suggestions, select that item const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); @@ -2169,7 +2182,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); // If typed prefix exactly matches one of the suggestions, select that item const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index e4664a05..f29b1be3 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,8 +1,13 @@ import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; -import { truncateToWidth } from "../utils.js"; +import { truncateToWidth, visibleWidth } from "../utils.js"; + +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim(); +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); export interface SelectItem { value: string; @@ -18,22 +23,38 @@ export interface SelectListTheme { noMatch: (text: string) => string; } +export interface SelectListTruncatePrimaryContext { + text: string; + maxWidth: number; + columnWidth: number; + item: SelectItem; + isSelected: boolean; +} + +export interface SelectListLayoutOptions { + minPrimaryColumnWidth?: number; + maxPrimaryColumnWidth?: number; + truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; +} + export class SelectList implements Component { private items: SelectItem[] = []; private filteredItems: SelectItem[] = []; private selectedIndex: number = 0; private maxVisible: number = 5; private theme: SelectListTheme; + private layout: SelectListLayoutOptions; public onSelect?: (item: SelectItem) => void; public onCancel?: () => void; public onSelectionChange?: (item: SelectItem) => void; - constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) { + constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) { this.items = items; this.filteredItems = items; this.maxVisible = maxVisible; this.theme = theme; + this.layout = layout; } setFilter(filter: string): void { @@ -59,6 +80,8 @@ export class SelectList implements Component { return lines; } + const primaryColumnWidth = this.getPrimaryColumnWidth(); + // Calculate visible range with scrolling const startIndex = Math.max( 0, @@ -73,68 +96,7 @@ export class SelectList implements Component { const isSelected = i === this.selectedIndex; const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined; - - let line = ""; - if (isSelected) { - // Use arrow indicator for selection - entire line uses selectedText color - const prefixWidth = 2; // "→ " is 2 characters visually - const displayValue = item.label || item.value; - - if (descriptionSingleLine && width > 40) { - // Calculate how much space we have for value + description - const maxValueWidth = Math.min(30, width - prefixWidth - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description using visible widths - const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); - // Apply selectedText to entire line content - line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`); - } else { - // Not enough space for description - const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); - } - } else { - // No description or not enough width - const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); - } - } else { - const displayValue = item.label || item.value; - const prefix = " "; - - if (descriptionSingleLine && width > 40) { - // Calculate how much space we have for value + description - const maxValueWidth = Math.min(30, width - prefix.length - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); - - // Calculate remaining space for description - const descriptionStart = prefix.length + truncatedValue.length + spacing.length; - const remainingWidth = width - descriptionStart - 2; // -2 for safety - - if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); - const descText = this.theme.description(spacing + truncatedDesc); - line = prefix + truncatedValue + descText; - } else { - // Not enough space for description - const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, ""); - } - } else { - // No description or not enough width - const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, ""); - } - } - - lines.push(line); + lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth)); } // Add scroll indicators if needed @@ -174,6 +136,85 @@ export class SelectList implements Component { } } + private renderItem( + item: SelectItem, + isSelected: boolean, + width: number, + descriptionSingleLine: string | undefined, + primaryColumnWidth: number, + ): string { + const prefix = isSelected ? "→ " : " "; + const prefixWidth = visibleWidth(prefix); + + if (descriptionSingleLine && width > 40) { + const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4)); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); + } + + const descText = this.theme.description(spacing + truncatedDesc); + return prefix + truncatedValue + descText; + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}`); + } + + return prefix + truncatedValue; + } + + private getPrimaryColumnWidth(): number { + const { min, max } = this.getPrimaryColumnBounds(); + const widestPrimary = this.filteredItems.reduce((widest, item) => { + return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP); + }, 0); + + return clamp(widestPrimary, min, max); + } + + private getPrimaryColumnBounds(): { min: number; max: number } { + const rawMin = + this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + + return { + min: Math.max(1, Math.min(rawMin, rawMax)), + max: Math.max(1, Math.max(rawMin, rawMax)), + }; + } + + private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string { + const displayValue = this.getDisplayValue(item); + const truncatedValue = this.layout.truncatePrimary + ? this.layout.truncatePrimary({ + text: displayValue, + maxWidth, + columnWidth, + item, + isSelected, + }) + : truncateToWidth(displayValue, maxWidth, ""); + + return truncateToWidth(truncatedValue, maxWidth, ""); + } + + private getDisplayValue(item: SelectItem): string { + return item.label || item.value; + } + private notifySelectionChange(): void { const selectedItem = this.filteredItems[this.selectedIndex]; if (selectedItem && this.onSelectionChange) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 5e8c90ab..437a3e39 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,7 +15,13 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.js export { Input } from "./components/input.js"; export { Loader } from "./components/loader.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; -export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js"; +export { + type SelectItem, + SelectList, + type SelectListLayoutOptions, + type SelectListTheme, + type SelectListTruncatePrimaryContext, +} from "./components/select-list.js"; export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js"; export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; diff --git a/packages/tui/test/select-list.test.ts b/packages/tui/test/select-list.test.ts index 5ea5766b..053690fe 100644 --- a/packages/tui/test/select-list.test.ts +++ b/packages/tui/test/select-list.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { SelectList } from "../src/components/select-list.js"; +import { visibleWidth } from "../src/utils.js"; const testTheme = { selectedPrefix: (text: string) => text, @@ -10,6 +11,12 @@ const testTheme = { noMatch: (text: string) => text, }; +const visibleIndexOf = (line: string, text: string): number => { + const index = line.indexOf(text); + assert.notEqual(index, -1); + return visibleWidth(line.slice(0, index)); +}; + describe("SelectList", () => { it("normalizes multiline descriptions to single line", () => { const items = [ @@ -27,4 +34,83 @@ describe("SelectList", () => { assert.ok(!rendered[0].includes("\n")); assert.ok(rendered[0].includes("Line one Line two Line three")); }); + + it("keeps descriptions aligned when the primary text is truncated", () => { + const items = [ + { value: "short", label: "short", description: "short description" }, + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "long description", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(80); + + assert.equal(visibleIndexOf(rendered[0], "short description"), visibleIndexOf(rendered[1], "long description")); + }); + + it("uses the configured minimum primary column width", () => { + const items = [ + { value: "a", label: "a", description: "first" }, + { value: "bb", label: "bb", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + assert.equal(rendered[0].indexOf("first"), 14); + assert.equal(rendered[1].indexOf("second"), 14); + }); + + it("uses the configured maximum primary column width", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + assert.equal(visibleIndexOf(rendered[0], "first"), 22); + assert.equal(visibleIndexOf(rendered[1], "second"), 22); + }); + + it("allows overriding primary truncation while preserving description alignment", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 12, + truncatePrimary: ({ text, maxWidth }) => { + if (text.length <= maxWidth) { + return text; + } + + return `${text.slice(0, Math.max(0, maxWidth - 1))}…`; + }, + }); + const rendered = list.render(80); + + assert.ok(rendered[0].includes("…")); + assert.equal(visibleIndexOf(rendered[0], "first"), visibleIndexOf(rendered[1], "second")); + }); });