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:
can1357
2026-06-12 03:03:11 +02:00
parent c35b41ed41
commit 2e0ffcca46
7 changed files with 408 additions and 16 deletions
+4
View File
@@ -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;
}
+4
View File
@@ -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
+65 -8
View File
@@ -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();
}
}
+42
View File
@@ -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();
});
});
+198
View File
@@ -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]]);
});
});
+79
View File
@@ -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();
});
});