pi-mono/packages/tui/test/select-list.test.ts
Petr Houška 3bae85274a fix(tui,coding-agent): handle selection page keys in all scrolling lists
tui.select.pageUp/pageDown were configurable but ignored by SelectList,
SettingsList, editor autocomplete, and most coding-agent selectors. In
fullscreen mode, the default PageUp/PageDown also scrolled the transcript
instead of reaching the focused list.

- add getSelectPageIndex() and handle page keys in SelectList, SettingsList,
  editor autocomplete, and the model, scoped models, auth, user message,
  settings submenu, llama search, and /mcp selectors
- add Component.capturesViewportInput: a focused component that sets it
  receives fullscreen viewport keys instead of the transcript, as a focused
  overlay already does
- let page keys move an open autocomplete menu before app actions, so a
  remapped Ctrl+V pages the menu instead of pasting an image

closes #7629

Co-authored-by: muyiyr <940955635@qq.com>
2026-10-28 19:05:21 +01:00

131 lines
4 KiB
TypeScript

import assert from "node:assert";
import { describe, it } from "node:test";
import { SelectList } from "../src/components/select-list.ts";
import { visibleWidth } from "../src/utils.ts";
const testTheme = {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
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 = [
{
value: "test",
label: "test",
description: "Line one\nLine two\nLine three",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(100);
assert.ok(rendered.length > 0);
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"));
});
it("moves by one visible page with selection page keys (#7629)", () => {
const items = Array.from({ length: 12 }, (_, index) => ({ value: `item-${index}`, label: `Item ${index}` }));
const list = new SelectList(items, 5, testTheme);
const selections: string[] = [];
list.onSelectionChange = (item) => selections.push(item.value);
for (const key of ["\x1b[6~", "\x1b[6~", "\x1b[6~", "\x1b[6~", "\x1b[5~"]) list.handleInput(key);
assert.deepStrictEqual(selections, ["item-5", "item-10", "item-11", "item-6"]);
list.setFilter("no match");
list.handleInput("\x1b[6~");
assert.deepStrictEqual(selections, ["item-5", "item-10", "item-11", "item-6"]);
});
});