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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user