feat(coding-agent): added mouse-driven /settings overlay navigation across tabs and sections

- Added mouse hover, wheel, and click routing for `/settings` rows and tabs.
- Changed `/settings` to open as a fullscreen alternate-screen overlay.
- Added hover, hit-testing, and wheel APIs across TabBar, SelectList, and SettingsList.
- Added SGR mouse parsing plus `routeMouse` dispatch and exported it from the `tui` entrypoint.
This commit is contained in:
can1357
2026-06-12 03:00:22 +02:00
parent 6b1ca33bf7
commit c35b41ed41
14 changed files with 763 additions and 320 deletions
+7 -3
View File
@@ -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)).
@@ -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<number, number>();
#tocClickRows = new Map<number, number>();
#bodyClickRows = new Set<number>();
/** 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;
@@ -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<typeof theme.symbol>[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: " <icon> " 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<string, string>();
#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: " <icon> " 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<typeof theme.symbol>[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) {
@@ -92,7 +92,15 @@ export class SelectorController {
showSettingsSelector(): void {
getAvailableThemes().then(availableThemes => {
this.showSelector(done => {
// 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()],
@@ -155,8 +163,15 @@ export class SelectorController {
},
},
);
return { component: selector, focus: selector };
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();
});
}
@@ -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),
};
}
@@ -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),
};
}
@@ -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);
+19 -2
View File
@@ -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
+39 -1
View File
@@ -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<SelectItem>;
#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);
}
}
+141 -32
View File
@@ -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<MouseRoutable>).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,
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 truncateToWidth(prefix + labelText + separator + valueText, 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
// 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);
+93 -15
View File
@@ -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
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(style(` ${tab.label} `));
chunks.push({ text: style(` ${labels[i]} `), tabIndex: i });
if (i < this.#tabs.length - 1) {
chunks.push(" ");
chunks.push({ text: " " });
}
}
// Navigation hint
if (this.showHint) {
chunks.push(" ");
chunks.push(this.#theme.hint("(tab to cycle)"));
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;
}
}
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;
}
}
+2
View File
@@ -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";
+55
View File
@@ -0,0 +1,55 @@
/**
* SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
*
* Mouse tracking is enabled only while a fullscreen overlay holds the
* alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
* fullscreen components hit-testing against their own rendered frame:
* the frame paints from screen row 0, hence `row`/`col` are exposed
* 0-based for direct indexing into rendered lines.
*/
/** A decoded SGR mouse report. */
export interface SgrMouseEvent {
/** Raw button code (bit 32 = motion, bit 64 = wheel, low bits = button). */
button: number;
/** 0-based column of the event. */
col: number;
/** 0-based row of the event. */
row: number;
/** True for a release report (`m` suffix). */
release: boolean;
/** Wheel direction: -1 up, 1 down, null when not a wheel event. */
wheel: -1 | 1 | null;
/** True when the pointer moved (hover or drag) rather than clicked. */
motion: boolean;
/** True for a left-button press (not motion, not release, not wheel). */
leftClick: boolean;
}
/**
* Decode an SGR mouse report, or return null when `data` is not one.
* Callers on hot keypress paths should pre-check `data.startsWith("\x1b[<")`
* before paying for the regex.
*/
export function parseSgrMouse(data: string): SgrMouseEvent | null {
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
if (!match) return null;
const button = Number(match[1]);
const col = Number(match[2]) - 1;
const row = Number(match[3]) - 1;
const release = match[4] === "m";
const wheel = button & 64 ? ((button & 1 ? 1 : -1) as 1 | -1) : null;
const motion = (button & 32) !== 0 && wheel === null;
const leftClick = !release && wheel === null && !motion && (button & 3) === 0;
return { button, col, row, release, wheel, motion, leftClick };
}
/**
* Implemented by components that accept routed mouse events at frame-local
* coordinates. Hosts translate screen coordinates to the component's own
* rendered lines before forwarding.
*/
export interface MouseRoutable {
/** `line`/`col` are 0-based within the component's rendered output. */
routeMouse(event: SgrMouseEvent, line: number, col: number): void;
}
+29 -9
View File
@@ -258,7 +258,7 @@ describe("SettingsList", () => {
]);
});
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", () => {