mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 11:30:29 +00:00
refactor(tui): clarify searchable select list width layout (#16378)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: fecbade822
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
committed by
GitHub
parent
f19eabee54
commit
4133f4bd37
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { visibleWidth } from "../../terminal/ansi.js";
|
||||
import { SearchableSelectList, type SearchableSelectListTheme } from "./searchable-select-list.js";
|
||||
|
||||
const mockTheme: SearchableSelectListTheme = {
|
||||
@@ -48,6 +49,60 @@ describe("SearchableSelectList", () => {
|
||||
expect(output).toContain(tail);
|
||||
});
|
||||
|
||||
it("does not show description layout at width 40 (boundary)", () => {
|
||||
const items = [
|
||||
{ value: "one", label: "one", description: "desc" },
|
||||
{ value: "two", label: "two", description: "desc" },
|
||||
];
|
||||
const list = new SearchableSelectList(items, 5, mockTheme);
|
||||
list.setSelectedIndex(1); // ensure first row is not selected so description styling is applied
|
||||
|
||||
const output = list.render(40).join("\n");
|
||||
expect(output).not.toContain("(desc)");
|
||||
});
|
||||
|
||||
it("shows description layout at width 41 (boundary)", () => {
|
||||
const items = [
|
||||
{ value: "one", label: "one", description: "desc" },
|
||||
{ value: "two", label: "two", description: "desc" },
|
||||
];
|
||||
const list = new SearchableSelectList(items, 5, mockTheme);
|
||||
list.setSelectedIndex(1); // ensure first row is not selected so description styling is applied
|
||||
|
||||
const output = list.render(41).join("\n");
|
||||
expect(output).toContain("(desc)");
|
||||
});
|
||||
|
||||
it("keeps ANSI-highlighted description rows within terminal width", () => {
|
||||
const ansiTheme: SearchableSelectListTheme = {
|
||||
selectedPrefix: (t) => t,
|
||||
selectedText: (t) => t,
|
||||
description: (t) => t,
|
||||
scrollInfo: (t) => t,
|
||||
noMatch: (t) => t,
|
||||
searchPrompt: (t) => t,
|
||||
searchInput: (t) => t,
|
||||
matchHighlight: (t) => `\u001b[31m${t}\u001b[0m`,
|
||||
};
|
||||
const label = `provider/${"x".repeat(80)}`;
|
||||
const items = [
|
||||
{ value: label, label, description: "Some description text that should not overflow" },
|
||||
{ value: "other", label: "other", description: "Other description" },
|
||||
];
|
||||
const list = new SearchableSelectList(items, 5, ansiTheme);
|
||||
list.setSelectedIndex(1); // make first row non-selected so description styling is applied
|
||||
|
||||
for (const ch of "provider") {
|
||||
list.handleInput(ch);
|
||||
}
|
||||
|
||||
const width = 80;
|
||||
const output = list.render(width);
|
||||
for (const line of output) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters items when typing", () => {
|
||||
const list = new SearchableSelectList(testItems, 5, mockTheme);
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export class SearchableSelectList implements Component {
|
||||
onCancel?: () => void;
|
||||
onSelectionChange?: (item: SelectItem) => void;
|
||||
|
||||
private static readonly DESCRIPTION_LAYOUT_MIN_WIDTH = 40;
|
||||
private static readonly DESCRIPTION_MIN_WIDTH = 12;
|
||||
private static readonly DESCRIPTION_SPACING_WIDTH = 2;
|
||||
// Keep a small right margin so we don't risk wrapping due to styling/terminal quirks.
|
||||
private static readonly RIGHT_MARGIN_WIDTH = 2;
|
||||
|
||||
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
@@ -218,23 +224,20 @@ export class SearchableSelectList implements Component {
|
||||
const prefixWidth = prefix.length;
|
||||
const displayValue = this.getItemLabel(item);
|
||||
|
||||
if (item.description && width > 40) {
|
||||
const minDescriptionWidth = 12;
|
||||
const spacingWidth = 2;
|
||||
const availableWidth = Math.max(1, width - prefixWidth - 2);
|
||||
|
||||
if (availableWidth > minDescriptionWidth + spacingWidth + 1) {
|
||||
const maxValueWidth = availableWidth - minDescriptionWidth - spacingWidth;
|
||||
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
||||
const description = item.description;
|
||||
if (description) {
|
||||
const descriptionLayout = this.getDescriptionLayout(width, prefixWidth);
|
||||
if (descriptionLayout) {
|
||||
const truncatedValue = truncateToWidth(displayValue, descriptionLayout.maxValueWidth, "");
|
||||
const valueText = this.highlightMatch(truncatedValue, query);
|
||||
|
||||
const usedByValue = visibleWidth(valueText);
|
||||
const remainingWidth = availableWidth - usedByValue;
|
||||
const remainingWidth = descriptionLayout.availableWidth - usedByValue;
|
||||
const descriptionWidth = remainingWidth - descriptionLayout.spacingWidth;
|
||||
|
||||
if (remainingWidth > spacingWidth + 1) {
|
||||
const descriptionWidth = remainingWidth - spacingWidth;
|
||||
const spacing = " ".repeat(spacingWidth);
|
||||
const truncatedDesc = truncateToWidth(item.description, descriptionWidth, "");
|
||||
if (descriptionWidth >= SearchableSelectList.DESCRIPTION_MIN_WIDTH) {
|
||||
const spacing = " ".repeat(descriptionLayout.spacingWidth);
|
||||
const truncatedDesc = truncateToWidth(description, descriptionWidth, "");
|
||||
// Highlight plain text first, then apply theme styling to avoid corrupting ANSI codes
|
||||
const highlightedDesc = this.highlightMatch(truncatedDesc, query);
|
||||
const descText = isSelected ? highlightedDesc : this.theme.description(highlightedDesc);
|
||||
@@ -251,6 +254,34 @@ export class SearchableSelectList implements Component {
|
||||
return isSelected ? this.theme.selectedText(line) : line;
|
||||
}
|
||||
|
||||
private getDescriptionLayout(
|
||||
width: number,
|
||||
prefixWidth: number,
|
||||
): { availableWidth: number; maxValueWidth: number; spacingWidth: number } | null {
|
||||
if (width <= SearchableSelectList.DESCRIPTION_LAYOUT_MIN_WIDTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
width - prefixWidth - SearchableSelectList.RIGHT_MARGIN_WIDTH,
|
||||
);
|
||||
const maxValueWidth =
|
||||
availableWidth -
|
||||
SearchableSelectList.DESCRIPTION_MIN_WIDTH -
|
||||
SearchableSelectList.DESCRIPTION_SPACING_WIDTH;
|
||||
|
||||
if (maxValueWidth < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
availableWidth,
|
||||
maxValueWidth,
|
||||
spacingWidth: SearchableSelectList.DESCRIPTION_SPACING_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isKeyRelease(keyData)) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user