From 2e0ffcca464a017b961c8fdd1b8c56d5d09c3a46 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 12 Jun 2026 03:03:11 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/components/settings-selector.ts | 24 ++- packages/tui/CHANGELOG.md | 4 + packages/tui/src/components/settings-list.ts | 73 ++++++- packages/tui/test/mouse.test.ts | 42 ++++ packages/tui/test/settings-list.test.ts | 198 ++++++++++++++++++ packages/tui/test/tab-bar.test.ts | 79 +++++++ 7 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 packages/tui/test/mouse.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6cb1d13d2..1530cbae1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index a404a0e83..4f29d92e0 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -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; } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d507755dd..bd29fc496 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7eefc7fe2..fa60ce5cc 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -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(); } } diff --git a/packages/tui/test/mouse.test.ts b/packages/tui/test/mouse.test.ts new file mode 100644 index 000000000..cc2b2e390 --- /dev/null +++ b/packages/tui/test/mouse.test.ts @@ -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[ { + 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(); + }); +}); diff --git a/packages/tui/test/settings-list.test.ts b/packages/tui/test/settings-list.test.ts index ce6123853..fdf171087 100644 --- a/packages/tui/test/settings-list.test.ts +++ b/packages/tui/test/settings-list.test.ts @@ -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 = []; + 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]]); + }); }); diff --git a/packages/tui/test/tab-bar.test.ts b/packages/tui/test/tab-bar.test.ts index fa9adf714..0d99cec7c 100644 --- a/packages/tui/test/tab-bar.test.ts +++ b/packages/tui/test/tab-bar.test.ts @@ -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(); + }); });