feat(tui): make select list column sizing configurable (#2154)

* feat(tui): make select list layout configurable

* feat(tui): tune select list column sizing

* test(tui): fix select list alignment assertions
This commit is contained in:
Markus Ylisiurunen
2026-03-14 16:50:00 +02:00
committed by GitHub
parent e5b5738255
commit c9a3d14aa5
8 changed files with 253 additions and 76 deletions

View File

@@ -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<ThinkingLevel, string> = {
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<ThinkingLevel, string> = {
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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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"));
});
});