feat(tui): added section-focus navigation support for settings interfaces
- Added `SettingsList` section-focus state with `sectionFocused`, `hasSectionFocusTargets`, and `toggleSectionFocus()`. - Implemented section-heading navigation with Tab/Shift+Tab, Up/Down focus jumps, and Enter/Escape exits. - Updated `/settings` input hints and fallback routing to tab-bar keys when section focus is unavailable. - Added tests for section-focus navigation, mouse parsing, and tab-bar behavior coverage.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed local memory consolidation on Responses-style models that reject user-only requests by sending a dedicated stage-two system prompt.
|
||||
|
||||
### Added
|
||||
|
||||
- Added mouse-driven interaction to `/settings`, including tab and setting row hover highlighting, wheel scrolling, and left-click activation for entries and submenus
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `/settings` keyboard navigation so `Tab` and `Shift+Tab` toggle focus between section headings and setting rows in sectioned lists, with `↑/↓` jumping sections, `←/→` switching tabs, and status text reflecting the active controls
|
||||
- Updated `/settings` on-screen navigation hints to match the new section-focus behavior (`↑/↓` and `Tab/Enter`) and tab-switching arrows
|
||||
- Changed `/settings` to open as a full-screen overlay on the alternate screen so it no longer shares space with the underlying transcript
|
||||
- Codex, Gemini, and Perplexity web search now route their OAuth bearers through the new `withOAuthAccess` driver: a 401 or usage-limit force-refreshes the same account and then rotates to a sibling instead of failing the search, while identity metadata (`chatgpt-account-id`, Google `projectId`) is re-derived from the refreshed credential on every retry.
|
||||
- Kagi web search, the xAI TTS tool, and model-discovery list fetches now resolve their bearers through `withAuth` with an auth-storage resolver instead of a one-shot key snapshot, gaining the same force-refresh + rotate retry on 401.
|
||||
@@ -24,6 +27,7 @@
|
||||
- Restyled the `/settings` chrome: the tab strip moved below the content as a label-less footer, the panel keeps one constant height across navigation, value changes, and submenus (no more viewport jumps after each change), and typing now runs a global cross-tab search — results group under per-tab headings, the footer shows live match counts with non-matching tabs muted, Tab hops between matching tabs, and Esc exits search landing on the selected result's tab.
|
||||
- Normalized `/settings` labels and descriptions: consistent Title Case labels (e.g. "Todo Auto-Clear Delay", "GitHub View Cache"), uniform unit placement, articles and verb-first phrasing in descriptions ("If false…"/"Whether to…" rewritten), and a stale browser-tool description (Ulixee Hero) corrected to the actual puppeteer/Chromium implementation.
|
||||
- `/settings` section headings are now underlined — the active section's heading stays bold, and headings outside the active section render dim with the same underline — so section boundaries read at a glance.
|
||||
- `/settings`: Tab now toggles keyboard focus between section headings and the setting rows — while headings are focused, ↑/↓ jump whole sections and Enter/Esc drop back into the rows — instead of cycling tabs. ←/→ still switch tabs everywhere, tabs without sections (e.g. Plugins) keep Tab as tab-switching, and the footer hint follows the focus state.
|
||||
- Image-generation (Antigravity, xAI, OpenRouter, Gemini) and xAI TTS request failures now throw pi-ai's typed `ProviderHttpError` carrying status and response headers instead of `Object.assign`-patched `Error`s.
|
||||
- Collapsed bash, ssh, and eval previews now cap the command/code section to a viewport-sized tail window (terminal rows minus a chrome reserve) that renders identically while streaming and after completion, with `ctrl+o` as the only way to uncap. Previously bash/ssh capped the command only while streaming and snapped it fully open the moment the tool finished, and eval never capped cell code at all.
|
||||
|
||||
|
||||
@@ -359,8 +359,11 @@ export class SettingsSelectorComponent implements Component {
|
||||
if (this.#currentTabId === "plugins") {
|
||||
return "Tab to switch tabs · Esc to close";
|
||||
}
|
||||
const jump = this.#hasSectionJump ? "PgUp/PgDn to jump sections · " : "";
|
||||
return `Enter/Space to change · ${jump}Tab to switch tabs · Type to search · Esc to close`;
|
||||
if (this.#currentList?.sectionFocused) {
|
||||
return "↑/↓ to jump sections · Tab/Enter to settings · ←/→ to switch tabs · Esc to close";
|
||||
}
|
||||
const nav = this.#hasSectionJump ? "Tab to jump sections · ←/→ to switch tabs" : "Tab to switch tabs";
|
||||
return `Enter/Space to change · ${nav} · Type to search · Esc to close`;
|
||||
}
|
||||
|
||||
/** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */
|
||||
@@ -987,12 +990,17 @@ export class SettingsSelectorComponent implements Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
matchesKey(data, "tab") ||
|
||||
matchesKey(data, "shift+tab") ||
|
||||
matchesKey(data, "left") ||
|
||||
matchesKey(data, "right")
|
||||
) {
|
||||
// Tab toggles keyboard focus between section headings and setting rows
|
||||
// (fast section hopping); tabs without sections keep Tab switching tabs.
|
||||
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
||||
if (this.#currentList?.hasSectionFocusTargets()) {
|
||||
this.#currentList.toggleSectionFocus();
|
||||
return;
|
||||
}
|
||||
this.#tabBar.handleInput(data);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
||||
this.#tabBar.handleInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `SettingsList.sidebarWidth` option for a fixed split-layout sidebar width
|
||||
@@ -11,16 +12,19 @@
|
||||
- Added exported SGR mouse utilities `parseSgrMouse`, `SgrMouseEvent`, and `MouseRoutable`
|
||||
- Added section support to `SettingsList`: `SettingItem.heading` rows split the list into sections, PgUp/PgDn (`tui.select.pageUp`/`pageDown`) jump between sections (or page when none exist), and wide renders use a split layout — section sidebar on the left, the active section's items on the right — falling back to inline heading rows when the width cannot fit both panes. Headings are skipped by navigation, excluded from search, and styled through the optional `SettingsListTheme.heading` (which receives a `dimmed` flag for headings outside the active section) and `section`.
|
||||
- Added a host-integration surface to `SettingsList`: a `SettingsListOptions` constructor arg (`layout` to force the flat layout, `typeToSearch: false` to hand the query to a parent, `emptyText`, `hint`), `selectItem(id)`, `getSelectedItem()`, `onSelectionChange`, `hasOpenSubmenu()`, and the exported `getSettingItemFilterText` helper.
|
||||
- Added keyboard section focus to `SettingsList`: `toggleSectionFocus()` / `sectionFocused` / `hasSectionFocusTargets()` flip Up/Down between row navigation and whole-section jumps — the cursor glyph parks on the active sidebar entry (or the active heading row in the flat layout) while the row cursor hides, Enter/Esc drop focus back to the rows, and any explicit row selection (`selectItem`, wheel, filtering) exits it.
|
||||
- Added muted tabs to `TabBar` (`Tab.muted` + `TabBarTheme.mutedTab`, skipped by keyboard navigation), `setTabs(tabs, activeId?)`/`setActiveById(id)` for re-rendering the strip without firing `onTabChange`, an optional empty label (drops the `Label:` prefix), and a `showHint` switch for the trailing "(tab to cycle)" hint.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `SettingsList` section-focused keyboard handling so `Up`/`Down` now jump between sections and `Enter`/`Escape` exit section focus before confirming or cancelling a setting
|
||||
- Changed `SettingsList` split layout at wide widths to render the full list in the right pane and dim items outside the active section instead of showing only the active-section rows
|
||||
- Changed `SettingsList` to omit the default hint row (and preceding blank line) when `options.hint` is set to an empty string
|
||||
- Changed tab-bar overflow handling to collapse tabs to their `short` forms before wrapping to multiple lines
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `SettingsList` to clear section-focus state when filtering items, changing data, scrolling with the mouse wheel, or selecting by ID so stale heading focus does not persist across interactions
|
||||
- `SettingsList` now renders every state — list, open submenu, filtered results, empty — at one stable height, so interacting with a bottom-anchored settings panel no longer resizes the live terminal region on each keystroke (which forced re-anchoring and could strand stale scrollback rows).
|
||||
|
||||
## [15.11.3] - 2026-06-11
|
||||
|
||||
@@ -100,6 +100,7 @@ export class SettingsList implements Component {
|
||||
#onCancel: () => void;
|
||||
#options: SettingsListOptions;
|
||||
#filterQuery = "";
|
||||
#sectionFocus = false;
|
||||
#lastNotifiedSelectionId: string | undefined;
|
||||
|
||||
/** Fired when the selected item changes (navigation, filtering, or setItems). */
|
||||
@@ -143,11 +144,32 @@ export class SettingsList implements Component {
|
||||
selectItem(id: string): boolean {
|
||||
const index = this.#filteredItems.findIndex(item => !item.heading && item.id === id);
|
||||
if (index === -1) return false;
|
||||
this.#sectionFocus = false;
|
||||
this.#selectedIndex = index;
|
||||
this.#notifySelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True while keyboard focus is on the section headings instead of the setting rows. */
|
||||
get sectionFocused(): boolean {
|
||||
return this.#sectionFocus;
|
||||
}
|
||||
|
||||
/** Whether section focus has anywhere to go: 2+ derived sections in the current view. */
|
||||
hasSectionFocusTargets(): boolean {
|
||||
return this.#sections().length >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle keyboard focus between section headings and setting rows. While
|
||||
* focused, Up/Down jump whole sections and Enter/Esc return to the rows.
|
||||
* Engages only when {@link hasSectionFocusTargets}; returns the new state.
|
||||
*/
|
||||
toggleSectionFocus(): boolean {
|
||||
this.#sectionFocus = !this.#sectionFocus && this.hasSectionFocusTargets();
|
||||
return this.#sectionFocus;
|
||||
}
|
||||
|
||||
/** True while an item submenu owns input. */
|
||||
hasOpenSubmenu(): boolean {
|
||||
return this.#submenuComponent !== null;
|
||||
@@ -171,6 +193,8 @@ export class SettingsList implements Component {
|
||||
/** Move the selection one step for a wheel notch. */
|
||||
handleWheel(delta: -1 | 1): void {
|
||||
if (this.#submenuComponent) return;
|
||||
// Wheel is row-level interaction: it returns focus to the rows.
|
||||
this.#sectionFocus = false;
|
||||
this.#moveSelection(delta);
|
||||
}
|
||||
|
||||
@@ -250,6 +274,7 @@ export class SettingsList implements Component {
|
||||
const selectedId = this.#filteredItems[this.#selectedIndex]?.id;
|
||||
this.#items = items;
|
||||
this.#applyFilter();
|
||||
if (this.#sectionFocus && !this.hasSectionFocusTargets()) this.#sectionFocus = false;
|
||||
|
||||
const nextIndex = selectedId ? this.#filteredItems.findIndex(item => item.id === selectedId) : -1;
|
||||
if (nextIndex >= 0) {
|
||||
@@ -262,6 +287,7 @@ export class SettingsList implements Component {
|
||||
|
||||
#setFilter(filter: string): void {
|
||||
this.#filterQuery = filter;
|
||||
if (filter.trim()) this.#sectionFocus = false;
|
||||
this.#applyFilter();
|
||||
this.#selectedIndex = this.#firstSelectableIndex();
|
||||
this.#notifySelection();
|
||||
@@ -432,12 +458,22 @@ export class SettingsList implements Component {
|
||||
return this.#padLines(this.#renderMainList(width));
|
||||
}
|
||||
|
||||
#renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number, dimmed = false): string {
|
||||
#renderItemRow(
|
||||
item: SettingItem,
|
||||
index: number,
|
||||
maxLabelWidth: number,
|
||||
rowWidth: number,
|
||||
dimmed = false,
|
||||
headingCursor = false,
|
||||
): string {
|
||||
if (item.heading) {
|
||||
const headingStyle = this.#theme.heading ?? ((text: string) => this.#theme.hint(text));
|
||||
return truncateToWidth(` ${headingStyle(item.label, dimmed)}`, Math.max(0, rowWidth));
|
||||
const prefix = headingCursor ? this.#theme.cursor : " ";
|
||||
return truncateToWidth(`${prefix}${headingStyle(item.label, dimmed)}`, Math.max(0, rowWidth));
|
||||
}
|
||||
const isSelected = index === this.#selectedIndex;
|
||||
// While section focus owns the keyboard, the row cursor hides so the
|
||||
// section cursor is the single focus indicator.
|
||||
const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
|
||||
const prefix = isSelected ? this.#theme.cursor : " ";
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
||||
@@ -500,8 +536,19 @@ export class SettingsList implements Component {
|
||||
const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
|
||||
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
|
||||
const visibleItems = this.#filteredItems.slice(startIndex, startIndex + viewportHeight);
|
||||
// In the flat layout the active section's heading row carries the
|
||||
// section-focus cursor (the split layout shows it in the sidebar).
|
||||
const active = sections[this.#activeSectionIndex(sections)];
|
||||
const focusedHeadingIndex = this.#sectionFocus && active?.name ? active.firstItemIndex - 1 : -1;
|
||||
const itemRows = visibleItems.map((item, index) =>
|
||||
this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth),
|
||||
this.#renderItemRow(
|
||||
item,
|
||||
startIndex + index,
|
||||
maxLabelWidth,
|
||||
itemRowWidth,
|
||||
false,
|
||||
startIndex + index === focusedHeadingIndex,
|
||||
),
|
||||
);
|
||||
visibleItems.forEach((item, index) => {
|
||||
this.#hitRows[index] = item.heading ? undefined : item.id;
|
||||
@@ -579,7 +626,9 @@ export class SettingsList implements Component {
|
||||
isActive ? this.#theme.label(text, true, false) : this.#theme.hint(text));
|
||||
const sidebarRows = sectionNames.map((name, i) => {
|
||||
const label = truncateToWidth(name, sidebarWidth - 4, Ellipsis.Omit);
|
||||
return ` ${sectionStyle(label, i === activeIndex)}${padding(sidebarWidth - 2 - visibleWidth(label))}`;
|
||||
// Section focus parks the cursor glyph on the active sidebar entry.
|
||||
const prefix = this.#sectionFocus && i === activeIndex ? this.#theme.cursor : " ";
|
||||
return `${prefix}${sectionStyle(label, i === activeIndex)}${padding(sidebarWidth - visibleWidth(prefix) - visibleWidth(label))}`;
|
||||
});
|
||||
|
||||
// Right pane: the whole list, continuously scrollable. The active
|
||||
@@ -651,6 +700,10 @@ export class SettingsList implements Component {
|
||||
this.clearSearch();
|
||||
return;
|
||||
}
|
||||
if (this.#sectionFocus) {
|
||||
this.#sectionFocus = false;
|
||||
return;
|
||||
}
|
||||
this.#onCancel();
|
||||
return;
|
||||
}
|
||||
@@ -662,15 +715,19 @@ export class SettingsList implements Component {
|
||||
if (this.#filteredItems.length === 0) return;
|
||||
|
||||
if (kb.matches(data, "tui.select.up")) {
|
||||
this.#moveSelection(-1);
|
||||
if (this.#sectionFocus) this.#jumpSection(-1);
|
||||
else this.#moveSelection(-1);
|
||||
} else if (kb.matches(data, "tui.select.down")) {
|
||||
this.#moveSelection(1);
|
||||
if (this.#sectionFocus) this.#jumpSection(1);
|
||||
else this.#moveSelection(1);
|
||||
} else if (kb.matches(data, "tui.select.pageDown")) {
|
||||
this.#jumpSection(1);
|
||||
} else if (kb.matches(data, "tui.select.pageUp")) {
|
||||
this.#jumpSection(-1);
|
||||
} else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
|
||||
this.#activateItem();
|
||||
// Confirm on a focused heading drops into its first setting.
|
||||
if (this.#sectionFocus) this.#sectionFocus = false;
|
||||
else this.#activateItem();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseSgrMouse } from "@oh-my-pi/pi-tui/mouse";
|
||||
|
||||
describe("parseSgrMouse", () => {
|
||||
it("returns null for non-mouse input", () => {
|
||||
expect(parseSgrMouse("a")).toBeNull();
|
||||
expect(parseSgrMouse("\x1b[A")).toBeNull();
|
||||
expect(parseSgrMouse("\x1b[<bogus")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes left clicks with 0-based coordinates", () => {
|
||||
const event = parseSgrMouse("\x1b[<0;5;9M");
|
||||
expect(event).toEqual({
|
||||
button: 0,
|
||||
col: 4,
|
||||
row: 8,
|
||||
release: false,
|
||||
wheel: null,
|
||||
motion: false,
|
||||
leftClick: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes releases as non-clicks", () => {
|
||||
const event = parseSgrMouse("\x1b[<0;5;9m");
|
||||
expect(event?.release).toBe(true);
|
||||
expect(event?.leftClick).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes wheel direction from the low button bit", () => {
|
||||
expect(parseSgrMouse("\x1b[<64;1;1M")?.wheel).toBe(-1);
|
||||
expect(parseSgrMouse("\x1b[<65;1;1M")?.wheel).toBe(1);
|
||||
expect(parseSgrMouse("\x1b[<65;1;1M")?.leftClick).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes motion reports without treating them as clicks", () => {
|
||||
const event = parseSgrMouse("\x1b[<35;10;3M");
|
||||
expect(event?.motion).toBe(true);
|
||||
expect(event?.leftClick).toBe(false);
|
||||
expect(event?.wheel).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -304,6 +304,92 @@ describe("SettingsList", () => {
|
||||
expect(flat).not.toContain("[dim-heading]");
|
||||
});
|
||||
|
||||
it("section focus routes arrows to section jumps, Enter drops into items, Esc exits without cancelling", () => {
|
||||
const changes: Array<[string, string]> = [];
|
||||
let cancelled = 0;
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
10,
|
||||
testTheme,
|
||||
(id, value) => {
|
||||
changes.push([id, value]);
|
||||
},
|
||||
() => {
|
||||
cancelled++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(list.hasSectionFocusTargets()).toBe(true);
|
||||
expect(list.toggleSectionFocus()).toBe(true);
|
||||
|
||||
// Down jumps a whole section (Group A → Group B), not one row.
|
||||
list.handleInput("\x1b[B");
|
||||
expect(list.getSelectedItem()?.id).toBe("beta");
|
||||
|
||||
// Enter returns focus to the rows without activating the setting…
|
||||
list.handleInput("\n");
|
||||
expect(list.sectionFocused).toBe(false);
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
// …after which Enter cycles the value again.
|
||||
list.handleInput("\n");
|
||||
expect(changes).toEqual([["beta", "on"]]);
|
||||
|
||||
// Esc exits section focus instead of cancelling the list.
|
||||
list.toggleSectionFocus();
|
||||
list.handleInput("\x1b");
|
||||
expect(list.sectionFocused).toBe(false);
|
||||
expect(cancelled).toBe(0);
|
||||
});
|
||||
|
||||
it("section focus cannot engage without sections and drops when a filter removes them", () => {
|
||||
const flat = new SettingsList(
|
||||
[{ id: "only", label: "Only", currentValue: "off", values: ["off", "on"] }],
|
||||
5,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
expect(flat.hasSectionFocusTargets()).toBe(false);
|
||||
expect(flat.toggleSectionFocus()).toBe(false);
|
||||
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
10,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
list.toggleSectionFocus();
|
||||
for (const ch of "alpha") list.handleInput(ch);
|
||||
expect(list.sectionFocused).toBe(false);
|
||||
});
|
||||
|
||||
it("moves the cursor glyph to the active section while section-focused", () => {
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
10,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const unfocused = list.render(120).join("\n");
|
||||
expect(unfocused).not.toContain("→ Group A");
|
||||
expect(unfocused).toContain("→ Alpha");
|
||||
|
||||
list.toggleSectionFocus();
|
||||
// Split layout: the sidebar entry carries the cursor and the row cursor hides.
|
||||
const split = list.render(120).join("\n");
|
||||
expect(split).toContain("→ Group A");
|
||||
expect(split).not.toContain("→ Alpha");
|
||||
|
||||
// Flat layout: the active heading row carries the cursor instead.
|
||||
const flat = list.render(60).join("\n");
|
||||
expect(flat).toContain("→ Group A");
|
||||
expect(flat).not.toContain("→ Alpha");
|
||||
});
|
||||
|
||||
it("falls back to inline heading rows when the width cannot fit the sidebar", () => {
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
@@ -354,4 +440,116 @@ describe("SettingsList", () => {
|
||||
["item11", "on"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves the selection with wheel events and reports it via onSelectionChange", () => {
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
10,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
const seen: Array<string | undefined> = [];
|
||||
list.onSelectionChange = item => seen.push(item?.id);
|
||||
|
||||
expect(list.getSelectedItem()?.id).toBe("alpha");
|
||||
list.handleWheel(1);
|
||||
expect(list.getSelectedItem()?.id).toBe("alpha2");
|
||||
list.handleWheel(-1);
|
||||
expect(list.getSelectedItem()?.id).toBe("alpha");
|
||||
expect(seen).toEqual(["alpha2", "alpha"]);
|
||||
});
|
||||
|
||||
it("hit-tests pane rows to items and sidebar rows to section jump targets", () => {
|
||||
const list = new SettingsList(
|
||||
sectionedItems(),
|
||||
10,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Split layout (wide): line 0 col 0 is the "Group A" sidebar row →
|
||||
// resolves to that section's first item for clicks, but never for hover.
|
||||
list.render(120);
|
||||
expect(list.hitTest(0, 0)).toBe("alpha");
|
||||
expect(list.hoverTest(0, 0)).toBeUndefined();
|
||||
// Sidebar row 1 (Group B) resolves to its first item.
|
||||
expect(list.hitTest(1, 0)).toBe("beta");
|
||||
// Pane rows resolve to the item they render: row 0 is the Group A
|
||||
// heading (not clickable), row 1 is Alpha.
|
||||
const paneCol = 40;
|
||||
expect(list.hitTest(0, paneCol)).toBeUndefined();
|
||||
expect(list.hitTest(1, paneCol)).toBe("alpha");
|
||||
expect(list.hoverTest(1, paneCol)).toBe("alpha");
|
||||
|
||||
// Flat layout (narrow): same rows, no sidebar region.
|
||||
list.render(60);
|
||||
expect(list.hitTest(0, 0)).toBeUndefined(); // heading row
|
||||
expect(list.hitTest(1, 0)).toBe("alpha");
|
||||
});
|
||||
|
||||
it("selects an item by id and resizes its viewport via setMaxVisible", () => {
|
||||
const items = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `item${i}`,
|
||||
label: `Item ${i}`,
|
||||
currentValue: "off",
|
||||
values: ["off", "on"] as string[],
|
||||
}));
|
||||
const list = new SettingsList(
|
||||
items,
|
||||
5,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(list.selectItem("item9")).toBe(true);
|
||||
expect(list.getSelectedItem()?.id).toBe("item9");
|
||||
expect(list.selectItem("missing")).toBe(false);
|
||||
|
||||
// A taller viewport renders more item rows (flat list, no sections).
|
||||
const before = list.render(60).join("\n");
|
||||
expect(before).not.toContain("Item 0\u0020");
|
||||
list.setMaxVisible(12);
|
||||
const after = list.render(60).join("\n");
|
||||
expect(after).toContain("Item 0");
|
||||
expect(after).toContain("Item 11");
|
||||
});
|
||||
|
||||
it("routes mouse events into an open submenu", () => {
|
||||
const routed: Array<[number, number, boolean]> = [];
|
||||
const submenu = {
|
||||
render: () => ["submenu line"],
|
||||
routeMouse: (event: { leftClick: boolean }, line: number, col: number) => {
|
||||
routed.push([line, col, event.leftClick]);
|
||||
},
|
||||
};
|
||||
const list = new SettingsList(
|
||||
[
|
||||
{
|
||||
id: "picker",
|
||||
label: "Picker",
|
||||
currentValue: "x",
|
||||
submenu: () => submenu,
|
||||
},
|
||||
],
|
||||
5,
|
||||
testTheme,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// No submenu open yet: nothing to route to.
|
||||
expect(list.routeSubmenuMouse({ leftClick: true } as never, 0, 0)).toBe(false);
|
||||
|
||||
list.handleInput("\n"); // open the submenu
|
||||
expect(list.hasOpenSubmenu()).toBe(true);
|
||||
// Open submenu swallows hit-testing for the outer rows.
|
||||
list.render(60);
|
||||
expect(list.hitTest(0, 0)).toBeUndefined();
|
||||
|
||||
expect(list.routeSubmenuMouse({ leftClick: true } as never, 2, 7)).toBe(true);
|
||||
expect(routed).toEqual([[2, 7, true]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,4 +38,83 @@ describe("TabBar", () => {
|
||||
const activeHighlights = rendered.match(/\x1b\[30;46m/g) ?? [];
|
||||
expect(activeHighlights.length).toBe(1);
|
||||
});
|
||||
|
||||
it("collapses distant tabs to their short form before wrapping", () => {
|
||||
const tabs = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `tab${i}`,
|
||||
label: `⊕ Section ${i}`,
|
||||
short: "⊕",
|
||||
}));
|
||||
const tabBar = new TabBar("", tabs, ansiTheme, 0);
|
||||
tabBar.showHint = false;
|
||||
|
||||
const lines = tabBar.render(60);
|
||||
// One line: distant tabs gave up their labels for icons.
|
||||
expect(lines.length).toBe(1);
|
||||
const text = lines[0];
|
||||
// The active tab always keeps its full label.
|
||||
expect(text).toContain("Section 0");
|
||||
// The farthest tab collapsed to its icon-only form.
|
||||
expect(text).not.toContain("Section 7");
|
||||
});
|
||||
|
||||
it("skips muted tabs during keyboard cycling and click selection", () => {
|
||||
const tabs = [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B", muted: true },
|
||||
{ id: "c", label: "C" },
|
||||
];
|
||||
const tabBar = new TabBar("", tabs, ansiTheme, 0);
|
||||
const changes: string[] = [];
|
||||
tabBar.onTabChange = tab => changes.push(tab.id);
|
||||
|
||||
tabBar.nextTab();
|
||||
expect(changes).toEqual(["c"]);
|
||||
|
||||
tabBar.prevTab();
|
||||
expect(changes).toEqual(["c", "a"]);
|
||||
|
||||
// Click selection refuses muted tabs but accepts normal ones.
|
||||
expect(tabBar.selectTab("b")).toBe(false);
|
||||
expect(tabBar.selectTab("c")).toBe(true);
|
||||
expect(changes).toEqual(["c", "a", "c"]);
|
||||
});
|
||||
|
||||
it("setTabs preserves the active tab by id without firing onTabChange", () => {
|
||||
const tabBar = new TabBar(
|
||||
"",
|
||||
[
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
],
|
||||
ansiTheme,
|
||||
1,
|
||||
);
|
||||
const changes: string[] = [];
|
||||
tabBar.onTabChange = tab => changes.push(tab.id);
|
||||
|
||||
// Reordered set: "b" moves to the front and must stay active.
|
||||
tabBar.setTabs([
|
||||
{ id: "b", label: "B (2)" },
|
||||
{ id: "a", label: "A", muted: true },
|
||||
]);
|
||||
expect(tabBar.getActiveTab().id).toBe("b");
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves tabs from pointer positions via per-render hit zones", () => {
|
||||
const tabs = [
|
||||
{ id: "first", label: "First" },
|
||||
{ id: "second", label: "Second" },
|
||||
];
|
||||
const tabBar = new TabBar("", tabs, ansiTheme, 0);
|
||||
const line = tabBar.render(80)[0];
|
||||
expect(visibleWidth(line)).toBeGreaterThan(0);
|
||||
|
||||
// ` First ` + " " + ` Second ` → col 0 is inside First,
|
||||
// col 9 (after the 7-wide button and 2-space gap) inside Second.
|
||||
expect(tabBar.tabAt(0, 1)?.id).toBe("first");
|
||||
expect(tabBar.tabAt(0, 10)?.id).toBe("second");
|
||||
expect(tabBar.tabAt(1, 1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user