diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 29e1b54a0..6cb1d13d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,26 +8,30 @@ - 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 +- Added fullscreen `/settings` mouse-event handling so scrolling and clicks work in an alternate-screen overlay - `ModelRegistry.resolver` now accepts a model directly — `resolver(model, sessionId)` — deriving `provider`, `baseUrl`, and `modelId` from it; all model-scoped call sites migrated from the verbose `resolver(model.provider, { sessionId, baseUrl, modelId })` form. - Added experimental `snapcompact.systemPrompt` and `snapcompact.toolResults` settings (off by default, `/settings` → Context → Experimental) that render the system prompt and large historical tool results as dense snapcompact PNG frames on vision-capable models to cut token cost. Frames are built per-request in the provider-context transform, cached across turns, capped by a per-provider image budget, and gated on a token-savings estimate — they never reach `session.jsonl`. ### Changed +- 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. - Compaction, handoff, and branch-summarization call sites now hand the compactor a per-candidate API-key resolver (availability still gated on a key snapshot), so credential refresh happens before the #986 fallback-model loop advances. - The mnemopi backend now passes an OpenRouter resolver for default embedding/extraction setups (AuthStorage-stored keys included), keeping pinned literal keys and custom endpoints unchanged. - Reorganized the `/settings` panel for findability: every tab now has titled sections backed by a per-tab layout contract (`TAB_GROUPS`); on wide terminals the panel renders a section sidebar with the active section's settings beside it (narrow terminals keep a flat list with inline headings), and PgUp/PgDn jump section-to-section. The Editing tab became Files (edit/read/LSP) and a new Shell tab hosts bash, eval, and Python settings. Misplaced settings were rehomed: bash toggles united under Shell, tool approval mode and policies together under Interaction → Approvals, marketplace auto-update next to startup update checks, and the todo auto-clear delay beside the other todo settings. +- 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. - 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. ### Fixed +- Fixed the settings sidebar divider alignment in `/settings` by locking sidebar width to the widest group name so switching tabs no longer shifts the layout +- Fixed plan-review overlay mouse hit-testing so wheel, hover, TOC, and body clicks map to the intended regions - Fixed `lsp.formatOnWrite` sending a hardcoded `tabSize: 3, insertSpaces: true` on every `textDocument/formatting` request, which silently re-indented 2-space YAML (and any LSP-formatted file) to 3-space on every write/edit through formatter-aware servers like `yaml-language-server`. Format options are now resolved per-file from `.editorconfig` (`indent_size`, `indent_style`, `tab_width`), falling back to the indent sniffed from the in-memory content the agent is about to write, then to a 2-space default. The duplicate `DEFAULT_FORMAT_OPTIONS` constant in `lsp/index.ts` and `lsp/clients/lsp-linter-client.ts` is replaced with a single shared `resolveFormatOptions` helper ([#2329](https://github.com/can1357/oh-my-pi/issues/2329)). - -### Fixed - - Fixed stale OpenAI Responses replay failures such as `Item with id 'rs_...' not found.` by resetting the provider session and retrying without advancing fallback chains. - Fixed `/settings` Escape handling so an open submenu receives Esc and returns to the settings list before a second Esc closes the panel ([#2331](https://github.com/can1357/oh-my-pi/issues/2331)). - Fixed Escape not closing `/settings` on the Plugins tab while the async plugin list is still loading: `PluginSettingsComponent` now closes on Esc while no child view is mounted, and an npm plugin registry listing failure is caught (like marketplace failures) so a bad registry no longer leaves the tab permanently blank ([#2331](https://github.com/can1357/oh-my-pi/issues/2331)). @@ -10127,4 +10131,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/plan-review-overlay.ts b/packages/coding-agent/src/modes/components/plan-review-overlay.ts index 29e3ac0e3..2757dc6e1 100644 --- a/packages/coding-agent/src/modes/components/plan-review-overlay.ts +++ b/packages/coding-agent/src/modes/components/plan-review-overlay.ts @@ -23,6 +23,7 @@ import { Markdown, type MarkdownTheme, matchesKey, + parseSgrMouse, ScrollView, truncateToWidth, visibleWidth, @@ -141,7 +142,7 @@ export class PlanReviewOverlay implements Component { #optionClickRows = new Map(); #tocClickRows = new Map(); #bodyClickRows = new Set(); - /** 1-based column at/under which a region-row click targets the sidebar. */ + /** Exclusive 0-based column bound below which a region-row click targets the sidebar. */ #sidebarClickMaxCol = 0; /** Option index the pointer is currently hovering, or undefined. Updated from * motion mouse reports and cleared when the pointer leaves the option rows. */ @@ -332,26 +333,23 @@ export class PlanReviewOverlay implements Component { * the body. */ #handleMouse(data: string): boolean { - const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data); - if (!match) return false; - const button = Number(match[1]); - const x = Number(match[2]); - const row = Number(match[3]) - 1; - if (button & 64) { - // Scroll wheel: low bit selects direction (64 up, 65 down). - this.#scrollView.scroll(button & 1 ? 3 : -3); + const event = parseSgrMouse(data); + if (!event) return false; + if (event.wheel !== null) { + // Scroll wheel: three rows per notch. + this.#scrollView.scroll(event.wheel * 3); return true; } - if (match[4] !== "M") return true; // release - if (button & 32) { + if (event.release) return true; + if (event.motion) { // Motion (hover or drag): light up the option row under the pointer so a // mouse user gets the same affordance the keyboard cursor gives. Any // non-option row clears the highlight. - this.#setHoveredOption(this.#optionClickRows.get(row)); + this.#setHoveredOption(this.#optionClickRows.get(event.row)); return true; } - if ((button & 3) !== 0) return true; // not the left button - const optionIndex = this.#optionClickRows.get(row); + if (!event.leftClick) return true; + const optionIndex = this.#optionClickRows.get(event.row); if (optionIndex !== undefined) { if (!this.#disabled.has(optionIndex)) { this.#focus = "actions"; @@ -360,14 +358,14 @@ export class PlanReviewOverlay implements Component { } return true; } - const tocPos = this.#tocClickRows.get(row); - if (tocPos !== undefined && x <= this.#sidebarClickMaxCol) { + const tocPos = this.#tocClickRows.get(event.row); + if (tocPos !== undefined && event.col < this.#sidebarClickMaxCol) { this.#focus = "toc"; this.#tocCursor = tocPos; this.#scrubBodyToToc(); return true; } - if (this.#bodyClickRows.has(row)) { + if (this.#bodyClickRows.has(event.row)) { this.#setFocus("body"); } return true; diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 83e31641a..a404a0e83 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -10,10 +10,12 @@ import { Input, matchesKey, padding, + parseSgrMouse, type SelectItem, SelectList, type SettingItem, SettingsList, + type SgrMouseEvent, Spacer, type Tab, TabBar, @@ -32,7 +34,7 @@ import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema"; import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme"; import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { getTabBarTheme } from "../shared"; -import { DynamicBorder } from "./dynamic-border"; +import { bottomBorder, divider, row, topBorder } from "./overlay-box"; import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings"; import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs"; import { getPreset } from "./status-line/presets"; @@ -86,6 +88,8 @@ class SelectSubmenu extends Container { #selectList: SelectList; #previewText: Text | null = null; #previewUpdateRequestId: number = 0; + #selectListLineOffset = 0; + #selectListLineCount = 0; constructor( title: string, @@ -165,73 +169,76 @@ class SelectSubmenu extends Container { } } + /** + * Concatenate children like Container.render, recording where the select + * list lands so routed mouse events can be hit-tested against it. + */ + override render(width: number): readonly string[] { + const lines: string[] = []; + for (const child of this.children) { + const childLines = child.render(Math.max(1, width)); + if (child === this.#selectList) { + this.#selectListLineOffset = lines.length; + this.#selectListLineCount = childLines.length; + } + lines.push(...childLines); + } + return lines; + } + + /** Mouse routed from the host: wheel steps, hover lights, click confirms. */ + routeMouse(event: SgrMouseEvent, line: number, _col: number): void { + if (event.wheel !== null) { + this.#selectList.handleWheel(event.wheel); + return; + } + const listLine = line - this.#selectListLineOffset; + const within = listLine >= 0 && listLine < this.#selectListLineCount; + const index = within ? this.#selectList.hitTest(listLine) : undefined; + if (event.motion) { + this.#selectList.setHoverIndex(index ?? null); + return; + } + if (event.leftClick && index !== undefined) { + this.#selectList.clickItem(index); + } + } + handleInput(data: string): void { this.#selectList.handleInput(data); } } +let cachedSidebarWidth: number | undefined; +/** + * Split-sidebar width derived from every group name in the schema (not just + * the visible tab), so the divider column never moves when switching tabs or + * when condition-gated groups appear. + */ +function settingsSidebarWidth(): number { + if (cachedSidebarWidth === undefined) { + let nameWidth = 0; + for (const tab of SETTING_TABS) { + for (const def of getSettingsForTab(tab)) { + if (def.group) nameWidth = Math.max(nameWidth, visibleWidth(def.group)); + } + } + cachedSidebarWidth = Math.min(22, nameWidth) + 4; + } + return cachedSidebarWidth; +} + function getSettingsTabs(): Tab[] { return [ ...SETTING_TABS.map(id => { const meta = TAB_METADATA[id]; const icon = theme.symbol(meta.icon as Parameters[0]); - return { id, label: `${icon} ${meta.label}` }; + return { id, label: `${icon} ${meta.label}`, short: icon }; }), - { id: "plugins", label: `${theme.icon.package} Plugins` }, + { id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package }, ]; } -/** - * Single-line search banner pinned above the settings content while a global - * search is active. Renders nothing when idle so it can stay permanently - * mounted between the top border and the tab content. - */ -class SettingsSearchHeader implements Component { - #query = ""; - #matchCount = 0; - #active = false; - - update(query: string, matchCount: number): void { - this.#active = true; - this.#query = query; - this.#matchCount = matchCount; - } - - clear(): void { - this.#active = false; - this.#query = ""; - this.#matchCount = 0; - } - - invalidate(): void {} - - render(width: number): readonly string[] { - if (!this.#active) return []; - - const icon = theme.symbol("icon.search"); - const countText = this.#matchCount === 1 ? "1 match" : `${this.#matchCount} matches`; - const rightWidth = visibleWidth(countText) + 1; // trailing margin - // Fixed chrome: " " prefix plus the "▌" cursor cell. - const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1); - - // Keep the tail visible (where the cursor is) when the query overflows. - let display = this.#query; - if (visibleWidth(display) > queryBudget) { - const chars = [...display]; - while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) { - chars.shift(); - } - display = `…${chars.join("")}`; - } - - const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`; - const count = theme.fg(this.#matchCount > 0 ? "dim" : "warning", countText); - const gap = Math.max(1, width - visibleWidth(left) - rightWidth); - const line = truncateToWidth(`${left}${padding(gap)}${count} `, width); - return [line, ""]; - } -} - /** * Dynamic context for settings that need runtime data. * Some settings (like thinking level) are managed by the session, not Settings. @@ -276,37 +283,32 @@ export interface SettingsCallbacks { * Main tabbed settings selector component. * Uses declarative settings definitions from settings-defs.ts. */ -export class SettingsSelectorComponent extends Container { +export class SettingsSelectorComponent implements Component { #tabBar: TabBar; - #searchHeader = new SettingsSearchHeader(); - #footer: Component[]; #currentList: SettingsList | null = null; #searchList: SettingsList | null = null; #pluginComponent: PluginSettingsComponent | null = null; - #statusPreviewContainer: Container | null = null; - #statusPreviewText: Text | null = null; #currentTabId: SettingTab | "plugins" = "appearance"; #preSearchTabId: SettingTab | "plugins" = "appearance"; #searchQuery = ""; + #searchMatchCount = 0; /** First matching item id per tab id, for Tab-key jumps while searching. */ #searchFirstMatch = new Map(); #textInputActive = false; + #hasSectionJump = false; + // Frame geometry from the last render, for mouse hit-testing (the + // fullscreen overlay paints from screen row 0, so mouse rows map 1:1). + #tabRowStart = 0; + #tabRowCount = 0; + #contentRowStart = 0; + #contentRowCount = 0; constructor( private readonly context: SettingsRuntimeContext, private readonly callbacks: SettingsCallbacks, ) { - super(); - - // Top border, then the search banner (renders nothing while idle). - this.addChild(new DynamicBorder()); - this.addChild(this.#searchHeader); - - // Tab bar lives at the bottom, under the tab content, so value rows and - // descriptions stay put (closest to where the user is looking) while - // tabs act as a footer. No label prefix — the panel context is obvious — - // and no "(tab to cycle)" hint: it is folded into the list footer so it - // never wraps onto a lone line under the tabs. + // No label prefix (the frame title already says Settings) and no + // "(tab to cycle)" hint (folded into the footer hint line). this.#tabBar = new TabBar("", getSettingsTabs(), getTabBarTheme()); this.#tabBar.showHint = false; this.#tabBar.onTabChange = () => { @@ -320,48 +322,23 @@ export class SettingsSelectorComponent extends Container { this.#switchToTab(tabId); }; - // Footer: spacer + tab bar + bottom border. #setContent inserts the - // active content above this footer. - this.#footer = [new Spacer(1), this.#tabBar, new DynamicBorder()]; - for (const child of this.#footer) { - this.addChild(child); - } - // Initialize with first tab this.#switchToTab("appearance"); } - /** - * Replace the tab content (everything between the search banner and the - * footer). Removes whichever content component is active, runs `build` to - * append the replacement, then re-attaches the footer below it. - */ - #setContent(build: () => void): void { - if (this.#currentList) { - this.removeChild(this.#currentList); - this.#currentList = null; - } - if (this.#searchList) { - this.removeChild(this.#searchList); - this.#searchList = null; - } - if (this.#pluginComponent) { - this.removeChild(this.#pluginComponent); - this.#pluginComponent = null; - } - if (this.#statusPreviewContainer) { - this.removeChild(this.#statusPreviewContainer); - this.#statusPreviewContainer = null; - this.#statusPreviewText = null; - } + invalidate(): void { + this.#tabBar.invalidate(); + this.#currentList?.invalidate(); + this.#searchList?.invalidate(); + this.#pluginComponent?.invalidate(); + } - for (const child of this.#footer) { - this.removeChild(child); - } + /** Swap the active content (per-tab list, search list, or plugins). */ + #setContent(build: () => void): void { + this.#currentList = null; + this.#searchList = null; + this.#pluginComponent = null; build(); - for (const child of this.#footer) { - this.addChild(child); - } } #switchToTab(tabId: SettingTab | "plugins"): void { @@ -375,6 +352,155 @@ export class SettingsSelectorComponent extends Container { }); } + #footerHintText(): string { + if (this.#searchList) { + return "Enter/Space to change · Tab to jump tabs · Backspace to edit · Esc to exit search"; + } + 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`; + } + + /** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */ + #renderSearchBanner(width: number): string { + const icon = theme.symbol("icon.search"); + const countText = this.#searchMatchCount === 1 ? "1 match" : `${this.#searchMatchCount} matches`; + const rightWidth = visibleWidth(countText) + 1; // trailing margin + // Fixed chrome: " " prefix plus the "▌" cursor cell. + const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1); + + // Keep the tail visible (where the cursor is) when the query overflows. + let display = this.#searchQuery; + if (visibleWidth(display) > queryBudget) { + const chars = [...display]; + while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) { + chars.shift(); + } + display = `…${chars.join("")}`; + } + + const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`; + const count = theme.fg(this.#searchMatchCount > 0 ? "dim" : "warning", countText); + const gap = Math.max(1, width - visibleWidth(left) - rightWidth); + return truncateToWidth(`${left}${padding(gap)}${count} `, width); + } + + /** + * Fullscreen frame: title border, tab row, divider, optional search banner, + * the active content sized to fill the terminal, the appearance preview, + * then a footer hint pinned above the bottom border. + */ + render(width: number): readonly string[] { + const height = Math.max(14, process.stdout.rows || 40); + const innerWidth = Math.max(1, width - 4); + + const tabLines = this.#tabBar.render(innerWidth); + const searching = this.#searchList !== null; + const showPreview = !searching && this.#currentTabId === "appearance"; + const previewLines = showPreview ? ["", theme.fg("muted", "Preview:"), this.#getStatusPreviewString()] : []; + + // Fixed chrome: top border, tabs, divider, [search row], divider, hint, bottom border. + const fixedRows = 1 + tabLines.length + 1 + (searching ? 1 : 0) + 1 + 1 + 1; + const contentRows = Math.max(7, height - fixedRows - previewLines.length); + + const list = this.#searchList ?? this.#currentList; + let contentLines: readonly string[]; + if (list) { + // SettingsList pads itself to viewport + blank + 3 description rows. + list.setMaxVisible(contentRows - 4); + contentLines = list.render(innerWidth); + } else if (this.#pluginComponent) { + contentLines = this.#pluginComponent.render(innerWidth); + } else { + contentLines = []; + } + + const out: string[] = []; + out.push(topBorder(width, "Settings")); + this.#tabRowStart = out.length; + this.#tabRowCount = tabLines.length; + for (const line of tabLines) { + out.push(row(line, width)); + } + out.push(divider(width)); + if (searching) { + out.push(row(this.#renderSearchBanner(innerWidth), width)); + } + this.#contentRowStart = out.length; + this.#contentRowCount = contentRows; + for (let i = 0; i < contentRows; i++) { + out.push(row(contentLines[i] ?? "", width)); + } + for (const line of previewLines) { + out.push(row(line, width)); + } + out.push(divider(width)); + out.push(row(theme.fg("dim", this.#footerHintText()), width)); + out.push(bottomBorder(width)); + return out; + } + + /** + * Route an SGR mouse report against the frame geometry of the last render. + * Wheel scrolls the focused list, motion drives the hover highlights (tabs + * and rows), and a left click activates: tabs switch (or jump, while + * searching), a row click selects, and a click on the already-selected row + * activates it (toggle / open submenu). + */ + #handleMouse(data: string): boolean { + const event = parseSgrMouse(data); + if (!event) return false; + + const list = this.#searchList ?? this.#currentList; + // row() insets content by two columns (border + space). + const innerCol = event.col - 2; + const contentLine = event.row - this.#contentRowStart; + + // An open submenu owns the pointer: wheel, hover, and clicks route into + // it (text-input submenus ignore routed events). + if (list?.hasOpenSubmenu()) { + list.routeSubmenuMouse(event, contentLine, innerCol); + return true; + } + + if (event.wheel !== null) { + list?.handleWheel(event.wheel); + return true; + } + + const tabLine = event.row - this.#tabRowStart; + const overTabs = tabLine >= 0 && tabLine < this.#tabRowCount; + const overContent = contentLine >= 0 && contentLine < this.#contentRowCount; + + if (event.motion) { + const hovered = overTabs ? this.#tabBar.tabAt(tabLine, innerCol) : undefined; + this.#tabBar.setHoverTab(hovered && !hovered.muted ? hovered.id : null); + // hoverTest: never light up pane rows while the pointer is on the + // sidebar — only rows the pointer is actually on. + list?.setHoverItem(overContent ? (list.hoverTest(contentLine, innerCol) ?? null) : null); + return true; + } + if (!event.leftClick) return true; + + if (overTabs) { + const tab = this.#tabBar.tabAt(tabLine, innerCol); + if (tab) this.#tabBar.selectTab(tab.id); + return true; + } + if (overContent && list) { + const id = list.hitTest(contentLine, innerCol); + if (id !== undefined) { + const wasSelected = list.getSelectedItem()?.id === id; + list.selectItem(id); + // Click-again activates: toggle booleans, open submenus. + if (wasSelected) list.handleInput("\n"); + } + } + return true; + } + // ═══════════════════════════════════════════════════════════════════════ // Global search (type-to-search across every tab) // ═══════════════════════════════════════════════════════════════════════ @@ -391,15 +517,14 @@ export class SettingsSelectorComponent extends Container { { layout: "flat", typeToSearch: false, - emptyText: "No matching settings — Backspace to edit, Esc to exit", - hint: "Enter/Space to change · Tab to jump tabs · Esc to exit search", + emptyText: "No matching settings", + hint: "", }, ); // Keep the footer tab highlight on the tab owning the selected result. list.onSelectionChange = item => this.#syncTabBarToSelection(item); this.#setContent(() => { this.#searchList = list; - this.addChild(list); }); this.#setSearchQuery(initialQuery); } @@ -443,7 +568,7 @@ export class SettingsSelectorComponent extends Container { } this.#searchList.setItems(items); - this.#searchHeader.update(query, total); + this.#searchMatchCount = total; this.#tabBar.setTabs(this.#buildSearchTabs(counts)); this.#syncTabBarToSelection(this.#searchList.getSelectedItem()); } @@ -461,7 +586,7 @@ export class SettingsSelectorComponent extends Container { this.#searchQuery = ""; this.#searchFirstMatch.clear(); - this.#searchHeader.clear(); + this.#searchMatchCount = 0; this.#tabBar.setTabs(getSettingsTabs(), targetTab); this.#switchToTab(targetTab); if (selectedDef) { @@ -478,13 +603,13 @@ export class SettingsSelectorComponent extends Container { const icon = theme.symbol(meta.icon as Parameters[0]); const count = counts.get(id) ?? 0; if (count > 0) { - matched.push({ id, label: `${icon} ${meta.label} (${count})` }); + matched.push({ id, label: `${icon} ${meta.label} (${count})`, short: `${icon} ${count}` }); } else { - empty.push({ id, label: `${icon} ${meta.label}`, muted: true }); + empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true }); } } // Plugins hosts its own UI; it is not part of the schema-backed search. - empty.push({ id: "plugins", label: `${theme.icon.package} Plugins`, muted: true }); + empty.push({ id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package, muted: true }); return [...matched, ...empty]; } @@ -637,7 +762,6 @@ export class SettingsSelectorComponent extends Container { rightSegments: presetDef.rightSegments, separator: presetDef.separator, }); - this.#updateStatusPreview(); }; onPreviewCancel = () => { const currentPreset = settings.get("statusLine.preset"); @@ -648,17 +772,14 @@ export class SettingsSelectorComponent extends Container { rightSegments: presetDef.rightSegments, separator: presetDef.separator, }); - this.#updateStatusPreview(); }; } else if (def.path === "statusLine.separator") { onPreview = value => { this.callbacks.onStatusLinePreview?.({ separator: value as StatusLineSeparatorStyle }); - this.#updateStatusPreview(); }; onPreviewCancel = () => { const separator = settings.get("statusLine.separator"); this.callbacks.onStatusLinePreview?.({ separator }); - this.#updateStatusPreview(); }; } @@ -738,23 +859,12 @@ export class SettingsSelectorComponent extends Container { #showSettingsTab(tabId: SettingTab): void { const defs = getSettingsForTab(tabId); - // Add status line preview for appearance tab - if (tabId === "appearance") { - this.#statusPreviewContainer = new Container(); - this.#statusPreviewContainer.addChild(new Spacer(1)); - this.#statusPreviewContainer.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0)); - this.#statusPreviewText = new Text(this.#getStatusPreviewString(), 0, 0); - this.#statusPreviewContainer.addChild(this.#statusPreviewText); - this.#statusPreviewContainer.addChild(new Spacer(1)); - this.addChild(this.#statusPreviewContainer); - } - const items = this.#buildItemsForDefs(defs); // Mirror SettingsList's section detection (leading ungrouped items form - // an implicit section) so the hint only advertises PgUp/PgDn when the - // jump actually changes sections. + // an implicit section) so the footer hint only advertises PgUp/PgDn + // when the jump actually changes sections. const sectionCount = items.filter(item => item.heading).length + (items.length > 0 && !items[0].heading ? 1 : 0); - const jumpHint = sectionCount >= 2 ? "PgUp/PgDn to jump sections · " : ""; + this.#hasSectionJump = sectionCount >= 2; this.#currentList = new SettingsList( items, @@ -786,15 +896,10 @@ export class SettingsSelectorComponent extends Container { this.#refreshCurrentTabItems(defs); }, () => this.callbacks.onCancel(), - // The selector owns type-to-search (global, cross-tab); disable the - // list's internal filter so the two never compete. - { - typeToSearch: false, - hint: `Enter/Space to change · ${jumpHint}Tab to switch tabs · Type to search · Esc to cancel`, - }, + // The selector owns type-to-search and the footer hint; pin the + // split sidebar width so the divider never jumps between tabs. + { typeToSearch: false, hint: "", sidebarWidth: settingsSidebarWidth() }, ); - - this.addChild(this.#currentList); } /** @@ -846,16 +951,6 @@ export class SettingsSelectorComponent extends Container { transparent: settings.get("statusLine.transparent"), }; this.callbacks.onStatusLinePreview?.(statusLineSettings); - this.#updateStatusPreview(); - } - - /** - * Update the inline status preview text. - */ - #updateStatusPreview(): void { - if (this.#statusPreviewText && this.#currentTabId === "appearance") { - this.#statusPreviewText.setText(this.#getStatusPreviewString()); - } } #showPluginsTab(): void { @@ -863,15 +958,15 @@ export class SettingsSelectorComponent extends Container { onClose: () => this.callbacks.onCancel(), onPluginChanged: () => this.callbacks.onPluginsChanged?.(), }); - this.addChild(this.#pluginComponent); - } - - getFocusComponent(): SettingsList | PluginSettingsComponent { - // Return the current focusable component - one of these will always be set - return (this.#searchList || this.#currentList || this.#pluginComponent)!; } handleInput(data: string): void { + // SGR mouse reports (the fullscreen overlay enables tracking). + if (data.startsWith("\x1b[<")) { + this.#handleMouse(data); + return; + } + // Text-input submenus take every byte: arrow keys must reach the // cursor and Tab must not switch tabs. if (this.#textInputActive) { diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 55c5c5783..692fc8f23 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -92,71 +92,86 @@ export class SelectorController { showSettingsSelector(): void { getAvailableThemes().then(availableThemes => { - this.showSelector(done => { - const selector = new SettingsSelectorComponent( - { - availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()], - thinkingLevel: this.ctx.session.thinkingLevel, - availableThemes, - cwd: getProjectDir(), - }, - { - onChange: (id, value) => this.handleSettingChange(id, value), - onThemePreview: async themeName => { - const result = await previewTheme(themeName); - if (result.success) { - this.ctx.statusLine.invalidate(); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.invalidate(); - this.ctx.ui.requestRender(); - } - }, - onStatusLinePreview: previewSettings => { - // Update status line with preview settings - this.ctx.statusLine.updateSettings({ - preset: settings.get("statusLine.preset"), - leftSegments: settings.get("statusLine.leftSegments"), - rightSegments: settings.get("statusLine.rightSegments"), - separator: settings.get("statusLine.separator"), - showHookStatus: settings.get("statusLine.showHookStatus"), - sessionAccent: settings.get("statusLine.sessionAccent"), - transparent: settings.get("statusLine.transparent"), - ...previewSettings, - }); + // Fullscreen settings editor on the alternate screen: the overlay + // enables mouse tracking (click/hover/wheel) for its lifetime and + // the transcript stays untouched underneath. + let overlayHandle: OverlayHandle | undefined; + const done = () => { + overlayHandle?.hide(); + this.ctx.ui.setFocus(this.ctx.editor); + this.ctx.ui.requestRender(); + }; + const selector = new SettingsSelectorComponent( + { + availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()], + thinkingLevel: this.ctx.session.thinkingLevel, + availableThemes, + cwd: getProjectDir(), + }, + { + onChange: (id, value) => this.handleSettingChange(id, value), + onThemePreview: async themeName => { + const result = await previewTheme(themeName); + if (result.success) { + this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); + this.ctx.ui.invalidate(); this.ctx.ui.requestRender(); - }, - getStatusLinePreview: () => { - // Return the rendered status line for inline preview - const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns); - return this.ctx.statusLine.getTopBorder(availableWidth).content; - }, - onPluginsChanged: async () => { - const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd()); - clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); - await this.ctx.refreshSlashCommandState(); - await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); - this.ctx.ui.requestRender(); - }, - onCancel: () => { - done(); - // Restore status line to saved settings - this.ctx.statusLine.updateSettings({ - preset: settings.get("statusLine.preset"), - leftSegments: settings.get("statusLine.leftSegments"), - rightSegments: settings.get("statusLine.rightSegments"), - separator: settings.get("statusLine.separator"), - showHookStatus: settings.get("statusLine.showHookStatus"), - sessionAccent: settings.get("statusLine.sessionAccent"), - transparent: settings.get("statusLine.transparent"), - }); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.requestRender(); - }, + } }, - ); - return { component: selector, focus: selector }; + onStatusLinePreview: previewSettings => { + // Update status line with preview settings + this.ctx.statusLine.updateSettings({ + preset: settings.get("statusLine.preset"), + leftSegments: settings.get("statusLine.leftSegments"), + rightSegments: settings.get("statusLine.rightSegments"), + separator: settings.get("statusLine.separator"), + showHookStatus: settings.get("statusLine.showHookStatus"), + sessionAccent: settings.get("statusLine.sessionAccent"), + transparent: settings.get("statusLine.transparent"), + ...previewSettings, + }); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.requestRender(); + }, + getStatusLinePreview: () => { + // Return the rendered status line for inline preview + const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns); + return this.ctx.statusLine.getTopBorder(availableWidth).content; + }, + onPluginsChanged: async () => { + const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd()); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + await this.ctx.refreshSlashCommandState(); + await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); + this.ctx.ui.requestRender(); + }, + onCancel: () => { + done(); + // Restore status line to saved settings + this.ctx.statusLine.updateSettings({ + preset: settings.get("statusLine.preset"), + leftSegments: settings.get("statusLine.leftSegments"), + rightSegments: settings.get("statusLine.rightSegments"), + separator: settings.get("statusLine.separator"), + showHookStatus: settings.get("statusLine.showHookStatus"), + sessionAccent: settings.get("statusLine.sessionAccent"), + transparent: settings.get("statusLine.transparent"), + }); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.requestRender(); + }, + }, + ); + overlayHandle = this.ctx.ui.showOverlay(selector, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, }); + this.ctx.ui.setFocus(selector); + this.ctx.ui.requestRender(); }); } diff --git a/packages/coding-agent/src/modes/shared.ts b/packages/coding-agent/src/modes/shared.ts index ab11c5107..a1289008b 100644 --- a/packages/coding-agent/src/modes/shared.ts +++ b/packages/coding-agent/src/modes/shared.ts @@ -25,6 +25,7 @@ export function getTabBarTheme(): TabBarTheme { activeTab: (text: string) => theme.bold(theme.bg("selectedBg", theme.fg("text", text))), inactiveTab: (text: string) => theme.fg("muted", text), mutedTab: (text: string) => theme.fg("dim", text), + hoverTab: (text: string) => theme.bg("selectedBg", theme.fg("text", text)), hint: (text: string) => theme.fg("dim", text), }; } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 951c60d22..204d70be2 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -9,7 +9,7 @@ import { highlightCode as nativeHighlightCode, supportsLanguage as nativeSupportsLanguage, } from "@oh-my-pi/pi-natives"; -import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; +import type { EditorTheme, MarkdownTheme, SelectListTheme, SettingsListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { LRUCache } from "lru-cache/raw"; @@ -2744,6 +2744,7 @@ export function getSelectListTheme(): SelectListTheme { scrollInfo: (text: string) => theme.fg("muted", text), noMatch: (text: string) => theme.fg("muted", text), symbols: getSymbolTheme(), + hovered: (text: string) => theme.bg("selectedBg", text), }; } @@ -2756,7 +2757,7 @@ export function getEditorTheme(): EditorTheme { }; } -export function getSettingsListTheme(): import("@oh-my-pi/pi-tui").SettingsListTheme { +export function getSettingsListTheme(): SettingsListTheme { return { label: (text: string, selected: boolean, changed: boolean) => changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : text, @@ -2765,8 +2766,10 @@ export function getSettingsListTheme(): import("@oh-my-pi/pi-tui").SettingsListT description: (text: string) => theme.fg("dim", text), cursor: theme.fg("accent", `${theme.nav.cursor} `), hint: (text: string) => theme.fg("dim", text), - heading: (text: string) => theme.fg("muted", theme.bold(text)), + heading: (text: string, dimmed: boolean) => + dimmed ? theme.fg("dim", theme.underline(text)) : theme.fg("muted", theme.bold(theme.underline(text))), section: (text: string, active: boolean) => active ? theme.fg("accent", theme.bold(text)) : theme.fg("muted", text), + hovered: (text: string) => theme.bg("selectedBg", text), }; } diff --git a/packages/coding-agent/test/session-color.test.ts b/packages/coding-agent/test/session-color.test.ts index 2b171fb6c..a62125e1c 100644 --- a/packages/coding-agent/test/session-color.test.ts +++ b/packages/coding-agent/test/session-color.test.ts @@ -8,6 +8,14 @@ const NO_THEME_COLORS: string[] = []; const lum = (hex: string): number => relativeLuminance(hex) ?? 0; const contrast = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +const saturatedThemeHues = (colors: string[]): number[] => { + const hues: number[] = []; + for (const color of colors) { + const { h, s } = hexToHsv(color); + if (s >= 0.1) hues.push(h); + } + return hues; +}; const names = Array.from({ length: 600 }, (_, i) => `analyze-debian-trixie-${i}`); @@ -70,7 +78,7 @@ describe("getSessionAccentHex with real Theme", () => { if (!theme) return; // skip if theme not found const colors = theme.getMajorThemeColorHexes(); const surface = theme.accentSurfaceLuminance; - const themeHues = colors.map(c => hexToHsv(c).h).filter(h => hexToHsv(colors[0]).s >= 0.1); + const themeHues = saturatedThemeHues(colors); for (const name of ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]) { const hex = getSessionAccentHex(name, colors, surface); @@ -88,7 +96,7 @@ describe("getSessionAccentHex with real Theme", () => { const theme = await getThemeByName("dark-catppuccin"); if (!theme) return; const colors = theme.getMajorThemeColorHexes(); - const themeHues = colors.map(c => hexToHsv(c).h).filter(h => hexToHsv(colors[0]).s >= 0.1); + const themeHues = saturatedThemeHues(colors); for (const name of ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]) { const hex = getSessionAccentHex(name, colors); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 417044dfc..d507755dd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,10 +1,27 @@ # Changelog ## [Unreleased] - ### Added -- 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`/`section`. +- Added `SettingsList.sidebarWidth` option for a fixed split-layout sidebar width +- Added mouse pointer support APIs to `SettingsList` with `setHoverItem`, `hitTest`, `hoverTest`, and `routeSubmenuMouse` for row targeting and submenu routing +- Added `SettingsList.setMaxVisible(rows)` and `SettingsList.handleWheel(delta)` for dynamic viewport sizing and mouse-wheel step selection +- Added compact tab features with new `Tab.short` labels and `TabBar.selectTab(id)` for id-based activation of non-muted tabs +- Added pointer-hover and hit-testing APIs to `TabBar` with `setHoverTab`, `tabAt`, and `hoverTab` theme +- 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 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` 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 + +- `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/select-list.ts b/packages/tui/src/components/select-list.ts index ab0ab7e01..04180c137 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -34,6 +34,8 @@ export interface SelectListTheme { scrollInfo: (text: string) => string; noMatch: (text: string) => string; symbols: SymbolTheme; + /** Hover band applied to the full row under the mouse pointer. */ + hovered?: (text: string) => string; } export interface SelectListTruncatePrimaryContext { @@ -81,6 +83,9 @@ export class SelectList implements Component { #filteredItems: ReadonlyArray; #filterQuery = ""; #selectedIndex: number = 0; + #hoveredIndex: number | null = null; + /** Per-render map of 0-based output line → filtered-item index. */ + #hitRows: (number | undefined)[] = []; onSelect?: (item: SelectItem) => void; onCancel?: () => void; @@ -103,12 +108,43 @@ export class SelectList implements Component { this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1)); } + /** Resolve a 0-based rendered-line index to a filtered-item index. */ + hitTest(line: number): number | undefined { + return this.#hitRows[line]; + } + + /** Highlight the item under the pointer (null clears). */ + setHoverIndex(index: number | null): void { + this.#hoveredIndex = index; + } + + /** Move the selection one step for a wheel notch. */ + handleWheel(delta: -1 | 1): void { + if (this.#filteredItems.length === 0) return; + const next = clamp(this.#selectedIndex + delta, 0, this.#filteredItems.length - 1); + if (next === this.#selectedIndex) return; + this.#selectedIndex = next; + this.#notifySelectionChange(); + } + + /** Mouse click: select the item under the pointer and confirm it. */ + clickItem(index: number): void { + const item = this.#filteredItems[index]; + if (!item) return; + if (index !== this.#selectedIndex) { + this.#selectedIndex = index; + this.#notifySelectionChange(); + } + this.onSelect?.(item); + } + invalidate(): void { // No cached state to invalidate currently } render(width: number): readonly string[] { const lines: string[] = []; + this.#hitRows = []; const showSearchStatus = this.#shouldRenderSearchStatus(); // If no items match filter, show message @@ -159,10 +195,12 @@ export class SelectList implements Component { for (let i = startIndex; i < endIndex && rows.length < visualBudget; i++) { const item = this.#filteredItems[i]; if (!item) continue; + const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex; const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth); for (const row of itemRows) { if (rows.length >= visualBudget) break; - rows.push(row); + this.#hitRows[rows.length] = i; + rows.push(hovered && this.theme.hovered ? this.theme.hovered(row) : row); } } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index f0e5e4141..7eefc7fe2 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,7 @@ import { fuzzyFilter } from "../fuzzy"; import { getKeybindings } from "../keybindings"; import { extractPrintableText } from "../keys"; +import type { MouseRoutable, SgrMouseEvent } from "../mouse"; import type { Component } from "../tui"; import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; import { ScrollView } from "./scroll-view"; @@ -37,10 +38,12 @@ export interface SettingsListTheme { description: (text: string) => string; cursor: string; hint: (text: string) => string; - /** Style for section heading rows. Falls back to `hint` when omitted. */ - heading?: (text: string) => string; + /** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */ + heading?: (text: string, dimmed: boolean) => string; /** Style for sidebar section names in the split layout. Falls back to label/hint. */ section?: (text: string, active: boolean) => string; + /** Hover band applied to the full row under the mouse pointer. */ + hovered?: (text: string) => string; } /** A contiguous run of items under one heading, derived from the item list. */ @@ -65,8 +68,14 @@ export interface SettingsListOptions { typeToSearch?: boolean; /** Text shown when the list has no items at all. */ emptyText?: string; - /** Footer hint line (hint-styled, replaces the default navigation hint). */ + /** + * Footer hint line (hint-styled, replaces the default navigation hint). + * An empty string removes the hint row and its leading blank entirely — + * use when the host renders its own footer. + */ hint?: string; + /** Fixed split-sidebar width (columns incl. indent+gap); default derives from section names. */ + sidebarWidth?: number; } /** Searchable text for a setting item: label, id, value, description, and cycle values. */ @@ -99,6 +108,12 @@ export class SettingsList implements Component { // Submenu state #submenuComponent: Component | null = null; #submenuItemId: string | null = null; + // Mouse support: hover highlight and per-render hit maps (content-line + // index → item id), rebuilt by every main-list render. + #hoveredItemId: string | null = null; + #hitRows: (string | undefined)[] = []; + #sidebarHitRows: (string | undefined)[] = []; + #sidebarHitCol = 0; constructor( items: SettingItem[], maxVisible: number, @@ -145,6 +160,60 @@ export class SettingsList implements Component { this.onSelectionChange?.(item); } + /** Resize the visible viewport (fullscreen hosts call this every render). */ + setMaxVisible(rows: number): void { + const next = Math.max(3, Math.floor(rows)); + if (next === this.#maxVisible) return; + this.#maxVisible = next; + this.#clampSelectedIndex(); + } + + /** Move the selection one step for a wheel notch. */ + handleWheel(delta: -1 | 1): void { + if (this.#submenuComponent) return; + this.#moveSelection(delta); + } + + /** Highlight the item under the pointer (null clears). */ + setHoverItem(id: string | null): void { + this.#hoveredItemId = id; + } + + /** + * Resolve a pointer position against the last rendered frame. `line` is the + * 0-based content-line index within this component's render output, `col` + * the 0-based column. Sidebar rows resolve to the section's first item. + */ + hitTest(line: number, col: number): string | undefined { + if (this.#submenuComponent) return undefined; + if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) { + return this.#sidebarHitRows[line]; + } + return this.#hitRows[line]; + } + + /** + * Like {@link hitTest}, but only rows the pointer is visually on: sidebar + * jump targets are excluded so hovering section names does not light up + * pane rows. + */ + hoverTest(line: number, col: number): string | undefined { + if (this.#submenuComponent) return undefined; + if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) return undefined; + return this.#hitRows[line]; + } + + /** + * Route a mouse event into an open submenu (coordinates are local to this + * list's rendered lines). Returns false when no submenu is open; submenus + * that do not implement {@link MouseRoutable} consume the event silently. + */ + routeSubmenuMouse(event: SgrMouseEvent, line: number, col: number): boolean { + if (!this.#submenuComponent) return false; + (this.#submenuComponent as Component & Partial).routeMouse?.(event, line, col); + return true; + } + getSearchQuery(): string { return this.#filterQuery; } @@ -336,9 +405,12 @@ export class SettingsList implements Component { * height forces the terminal to re-anchor and can strand scrollback rows. */ #stableHeight(): number { - // viewport + description (1 blank + 3 lines) + search status + blank + hint. - // Without internal type-to-search the status row is never rendered. - return this.#maxVisible + (this.#options.typeToSearch === false ? 6 : 7); + // viewport + blank + 3 description rows, plus the optional search status + // row and the optional blank+hint footer. + let height = this.#maxVisible + 4; + if (this.#options.typeToSearch !== false) height += 1; + if (this.#options.hint !== "") height += 2; + return height; } #padLines(lines: string[]): string[] { @@ -347,6 +419,10 @@ export class SettingsList implements Component { } render(width: number): readonly string[] { + // Hit maps describe exactly the frame being produced now. + this.#hitRows = []; + this.#sidebarHitRows = []; + this.#sidebarHitCol = 0; // If submenu is active, render it instead (padded to the list's stable // height so opening/closing a submenu does not resize the panel). if (this.#submenuComponent) { @@ -356,24 +432,36 @@ export class SettingsList implements Component { return this.#padLines(this.#renderMainList(width)); } - #renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number): string { + #renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number, dimmed = false): string { if (item.heading) { - const headingStyle = this.#theme.heading ?? this.#theme.hint; - return truncateToWidth(` ${headingStyle(item.label)}`, Math.max(0, rowWidth)); + const headingStyle = this.#theme.heading ?? ((text: string) => this.#theme.hint(text)); + return truncateToWidth(` ${headingStyle(item.label, dimmed)}`, Math.max(0, rowWidth)); } const isSelected = index === this.#selectedIndex; const prefix = isSelected ? this.#theme.cursor : " "; const prefixWidth = visibleWidth(prefix); const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label))); - const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true); const separator = " "; const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2; - const valueText = this.#theme.value( - truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit), - isSelected, - item.changed === true, - ); - return truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth)); + const valuePlain = truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit); + const hovered = !isSelected && this.#theme.hovered !== undefined && item.id === this.#hoveredItemId; + // De-emphasized rows (outside the active section) render as plain text + // under one dim wash so inner label/value colors don't fight it. + if (dimmed && !isSelected) { + const text = this.#theme.hint( + truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)), + ); + return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text; + } + const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true); + const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true); + const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth)); + // Pointer hover paints a band behind the whole row, distinct from the + // keyboard selection (cursor glyph + accent) which stays where it is. + if (hovered && this.#theme.hovered) { + return this.#theme.hovered(text); + } + return text; } #renderMainList(width: number): string[] { @@ -415,6 +503,9 @@ export class SettingsList implements Component { const itemRows = visibleItems.map((item, index) => this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth), ); + visibleItems.forEach((item, index) => { + this.#hitRows[index] = item.heading ? undefined : item.id; + }); const scrollView = new ScrollView(itemRows, { height: viewportHeight, scrollbar: "auto", @@ -452,26 +543,29 @@ export class SettingsList implements Component { lines.push(this.#renderSearchStatus(width)); } - // Add hint - lines.push(""); - const jumpHint = sections.length >= 2 ? "PgUp/PgDn to jump sections · " : ""; - const hintText = this.#options.hint ?? `Enter/Space to change · ${jumpHint}Type to search · Esc to cancel`; - lines.push(truncateToWidth(this.#theme.hint(` ${hintText}`), width)); + // Add hint (suppressed entirely when the host owns the footer) + if (this.#options.hint !== "") { + lines.push(""); + const jumpHint = sections.length >= 2 ? "PgUp/PgDn to jump sections · " : ""; + const hintText = this.#options.hint ?? `Enter/Space to change · ${jumpHint}Type to search · Esc to cancel`; + lines.push(truncateToWidth(this.#theme.hint(` ${hintText}`), width)); + } return lines; } /** - * Split layout: section sidebar on the left, the active section's items on - * the right. Up/Down navigation still flows across section boundaries; the - * sidebar highlight follows the selection. Returns null when the width - * cannot fit both panes, falling back to the flat single-column layout. + * Split layout: section sidebar on the left, every item on the right with + * rows outside the active section dimmed so the section under the cursor + * pops. Up/Down navigation flows across section boundaries; the sidebar + * highlight follows the selection. Returns null when the width cannot fit + * both panes, falling back to the flat single-column layout. */ #renderSplitList(width: number, sections: SettingSection[]): string[] | null { const sectionNames = sections.map(section => section.name || "Other"); let nameWidth = 0; for (const name of sectionNames) nameWidth = Math.max(nameWidth, visibleWidth(name)); - const sidebarWidth = Math.min(22, nameWidth) + 4; // 2-space indent + 2-space gap + const sidebarWidth = this.#options.sidebarWidth ?? Math.min(22, nameWidth) + 4; // 2-space indent + 2-space gap const paneWidth = width - sidebarWidth - 2; // "│ " separator // Below this the value column starves (2 prefix + 30 label + 2 gap + ~25 value). if (paneWidth < 60) return null; @@ -488,27 +582,31 @@ export class SettingsList implements Component { return ` ${sectionStyle(label, i === activeIndex)}${padding(sidebarWidth - 2 - visibleWidth(label))}`; }); - // Right pane: only the active section's items. - const itemIndices: number[] = []; - for (let i = active.firstItemIndex; i <= active.lastItemIndex; i++) itemIndices.push(i); - const viewportHeight = Math.min(this.#maxVisible, itemIndices.length); - const selectedRow = Math.max(0, this.#selectedIndex - active.firstItemIndex); + // Right pane: the whole list, continuously scrollable. The active + // section's heading row belongs to its dim-exempt range. + const activeStart = active.name ? active.firstItemIndex - 1 : active.firstItemIndex; + const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length); const startRow = Math.max( 0, - Math.min(selectedRow - Math.floor(viewportHeight / 2), itemIndices.length - viewportHeight), + Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight), ); // Label column width spans all items so the layout stays stable across sections. const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label)); const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0); - const overflow = itemIndices.length > viewportHeight; + const overflow = this.#filteredItems.length > viewportHeight; const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0)); - const itemRows = itemIndices - .slice(startRow, startRow + viewportHeight) - .map(index => this.#renderItemRow(this.#filteredItems[index], index, maxLabelWidth, rowWidth)); + const itemRows: string[] = []; + for (let r = 0; r < viewportHeight; r++) { + const index = startRow + r; + const item = this.#filteredItems[index]; + if (!item) break; + const dimmed = index < activeStart || index > active.lastItemIndex; + itemRows.push(this.#renderItemRow(item, index, maxLabelWidth, rowWidth, dimmed)); + } const scrollView = new ScrollView(itemRows, { height: viewportHeight, scrollbar: "auto", - totalRows: itemIndices.length, + totalRows: this.#filteredItems.length, theme: { track: text => this.#theme.hint(text), thumb: text => this.#theme.label(text, true, false), @@ -517,6 +615,17 @@ export class SettingsList implements Component { scrollView.setScrollOffset(startRow); const paneRows = scrollView.render(paneWidth); + // Hit maps: sidebar rows resolve to each section's first item; pane rows + // to the item they render. + this.#sidebarHitCol = sidebarWidth; + for (let i = 0; i < sectionNames.length; i++) { + this.#sidebarHitRows[i] = this.#filteredItems[sections[i].firstItemIndex]?.id; + } + for (let r = 0; r < viewportHeight; r++) { + const item = this.#filteredItems[startRow + r]; + if (item && !item.heading) this.#hitRows[r] = item.id; + } + const separator = this.#theme.hint("│ "); const lines: string[] = []; const height = Math.max(this.#maxVisible, sidebarRows.length); diff --git a/packages/tui/src/components/tab-bar.ts b/packages/tui/src/components/tab-bar.ts index 58d8eb688..3a9e4a6ab 100644 --- a/packages/tui/src/components/tab-bar.ts +++ b/packages/tui/src/components/tab-bar.ts @@ -18,6 +18,8 @@ export interface Tab { id: string; /** Display label shown in the tab bar */ label: string; + /** Compact form (e.g. just the icon) used when the bar must shrink to fit one line. */ + short?: string; /** Render with the muted style and skip during keyboard navigation. */ muted?: boolean; } @@ -34,6 +36,8 @@ export interface TabBarTheme { hint: (text: string) => string; /** Style for muted tabs. Falls back to `inactiveTab` when omitted. */ mutedTab?: (text: string) => string; + /** Style for the tab under the mouse pointer. Falls back to `inactiveTab` when omitted. */ + hoverTab?: (text: string) => string; } /** @@ -54,6 +58,9 @@ export class TabBar implements Component { #activeIndex: number = 0; #theme: TabBarTheme; #label: string; + #hoverTabId: string | null = null; + /** Per-render tab hit zones: 0-based line + [start, end) columns. */ + #hitZones: { line: number; start: number; end: number; index: number }[] = []; /** Callback fired when the active tab changes */ onTabChange?: (tab: Tab, index: number) => void; @@ -107,6 +114,14 @@ export class TabBar implements Component { return true; } + /** Activate the tab with `id`, firing onTabChange when it changes. Muted tabs are ignored. */ + selectTab(id: string): boolean { + const index = this.#tabs.findIndex(tab => tab.id === id); + if (index === -1 || this.#tabs[index]?.muted) return false; + this.setActiveIndex(index); + return true; + } + /** Move to the next non-muted tab (wraps to first tab after last) */ nextTab(): void { this.#stepTab(1); @@ -150,45 +165,78 @@ export class TabBar implements Component { return false; } - /** Render the tab bar, wrapping to multiple lines if needed */ + /** + * Render the tab bar. When the full labels overflow the width, tabs are + * collapsed to their `short` form one by one — starting with the tabs + * farthest from the active one — until the bar fits on a single line. + * Wrapping to multiple lines is the last resort. + */ render(width: number): readonly string[] { const maxWidth = Math.max(1, width); - const chunks: string[] = []; - // Label prefix (omitted when the label is empty) - if (this.#label) { - chunks.push(this.#theme.label(`${this.#label}:`)); - chunks.push(" "); + interface TabChunk { + text: string; + /** Index into #tabs when this chunk is a clickable tab button. */ + tabIndex?: number; } - // Tab buttons - for (let i = 0; i < this.#tabs.length; i++) { - const tab = this.#tabs[i]; - // Muted tabs never take the active highlight: they are skipped by - // navigation and only become "active" transiently via setTabs swaps. - const style = tab.muted - ? (this.#theme.mutedTab ?? this.#theme.inactiveTab) - : i === this.#activeIndex - ? this.#theme.activeTab - : this.#theme.inactiveTab; - chunks.push(style(` ${tab.label} `)); - if (i < this.#tabs.length - 1) { - chunks.push(" "); + const buildChunks = (labels: readonly string[]): TabChunk[] => { + const chunks: TabChunk[] = []; + // Label prefix (omitted when the label is empty) + if (this.#label) { + chunks.push({ text: this.#theme.label(`${this.#label}:`) }); + chunks.push({ text: " " }); + } + for (let i = 0; i < this.#tabs.length; i++) { + const tab = this.#tabs[i]; + // Muted tabs never take the active highlight: they are skipped by + // navigation and only become "active" transiently via setTabs swaps. + // A hovered (non-active) tab lights up so mouse users see the target. + const hovered = tab.id === this.#hoverTabId && !tab.muted && i !== this.#activeIndex; + const style = tab.muted + ? (this.#theme.mutedTab ?? this.#theme.inactiveTab) + : i === this.#activeIndex + ? this.#theme.activeTab + : hovered + ? (this.#theme.hoverTab ?? this.#theme.inactiveTab) + : this.#theme.inactiveTab; + chunks.push({ text: style(` ${labels[i]} `), tabIndex: i }); + if (i < this.#tabs.length - 1) { + chunks.push({ text: " " }); + } + } + // Navigation hint + if (this.showHint) { + chunks.push({ text: " " }); + chunks.push({ text: this.#theme.hint("(tab to cycle)") }); + } + return chunks; + }; + const totalWidth = (chunks: TabChunk[]): number => + chunks.reduce((sum, chunk) => sum + visibleWidth(chunk.text), 0); + + const labels = this.#tabs.map(tab => tab.label); + let chunks = buildChunks(labels); + + if (totalWidth(chunks) > maxWidth) { + const collapseOrder = this.#tabs + .map((_, index) => index) + .filter(index => index !== this.#activeIndex && this.#tabs[index].short !== undefined) + .sort((a, b) => Math.abs(b - this.#activeIndex) - Math.abs(a - this.#activeIndex)); + for (const index of collapseOrder) { + labels[index] = this.#tabs[index].short ?? this.#tabs[index].label; + chunks = buildChunks(labels); + if (totalWidth(chunks) <= maxWidth) break; } } - // Navigation hint - if (this.showHint) { - chunks.push(" "); - chunks.push(this.#theme.hint("(tab to cycle)")); - } - + this.#hitZones = []; const lines: string[] = []; let currentLine = ""; let currentWidth = 0; for (const chunk of chunks) { - const chunkWidth = visibleWidth(chunk); + const chunkWidth = visibleWidth(chunk.text); if (chunkWidth <= 0) { continue; } @@ -199,7 +247,10 @@ export class TabBar implements Component { currentLine = ""; currentWidth = 0; } - lines.push(truncateToWidth(chunk, maxWidth)); + if (chunk.tabIndex !== undefined) { + this.#hitZones.push({ line: lines.length, start: 0, end: maxWidth, index: chunk.tabIndex }); + } + lines.push(truncateToWidth(chunk.text, maxWidth)); continue; } @@ -209,7 +260,15 @@ export class TabBar implements Component { currentWidth = 0; } - currentLine += chunk; + if (chunk.tabIndex !== undefined) { + this.#hitZones.push({ + line: lines.length, + start: currentWidth, + end: currentWidth + chunkWidth, + index: chunk.tabIndex, + }); + } + currentLine += chunk.text; currentWidth += chunkWidth; } @@ -219,4 +278,23 @@ export class TabBar implements Component { return lines.length > 0 ? lines : [""]; } + + /** + * Resolve a pointer position against the last rendered frame. `line` is the + * 0-based line index within this component's render output, `col` the + * 0-based column. + */ + tabAt(line: number, col: number): Tab | undefined { + for (const zone of this.#hitZones) { + if (zone.line === line && col >= zone.start && col < zone.end) { + return this.#tabs[zone.index]; + } + } + return undefined; + } + + /** Highlight the tab under the pointer (null clears). */ + setHoverTab(id: string | null): void { + this.#hoverTabId = id; + } } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 33928af46..40dbe0f8d 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -29,6 +29,8 @@ export * from "./keybindings"; export * from "./keys"; // Kitty graphics: Unicode placeholders export * from "./kitty-graphics"; +// SGR mouse report parsing +export * from "./mouse"; // Mermaid diagram support // Input buffering for batch splitting export * from "./stdin-buffer"; diff --git a/packages/tui/src/mouse.ts b/packages/tui/src/mouse.ts new file mode 100644 index 000000000..e3b43bef5 --- /dev/null +++ b/packages/tui/src/mouse.ts @@ -0,0 +1,55 @@ +/** + * SGR mouse report parsing (`\x1b[ { ]); }); - it("renders a section sidebar at wide widths showing only the active section's items", () => { + it("renders a section sidebar at wide widths with the whole list in the pane", () => { const list = new SettingsList( sectionedItems(), 10, @@ -272,16 +272,36 @@ describe("SettingsList", () => { expect(output).toMatch(/Group A\s+│/); expect(output).toMatch(/Group B\s+│/); expect(output).toMatch(/Group C\s+│/); - // Pane shows the active section's items only + // Pane shows every item — sections outside the active one stay visible expect(output).toContain("Alpha"); - expect(output).not.toContain("Beta"); - expect(output).not.toContain("Gamma"); + expect(output).toContain("Beta"); + expect(output).toContain("Gamma"); + }); - // Jump to Group B: the pane swaps to its items - list.handleInput("\x1b[6~"); - const after = list.render(120).join("\n"); - expect(after).toContain("Beta"); - expect(after).not.toContain("Alpha"); + it("styles heading rows through theme.heading with the dimmed flag for out-of-section headings", () => { + const themed: SettingsListTheme = { + ...testTheme, + heading: (text: string, dimmed: boolean) => (dimmed ? `[dim-heading]${text}` : `[heading]${text}`), + }; + const list = new SettingsList( + sectionedItems(), + 10, + themed, + () => {}, + () => {}, + ); + + // Split layout: the active section's heading is bright, the rest dim. + const split = list.render(120).join("\n"); + expect(split).toContain("[heading]Group A"); + expect(split).toContain("[dim-heading]Group B"); + expect(split).toContain("[dim-heading]Group C"); + + // Flat layout has no active section: every heading renders undimmed. + const flat = list.render(60).join("\n"); + expect(flat).toContain("[heading]Group A"); + expect(flat).toContain("[heading]Group B"); + expect(flat).not.toContain("[dim-heading]"); }); it("falls back to inline heading rows when the width cannot fit the sidebar", () => {