Merge PR #3402: Shared TUI mouse-routing helpers and overlay selector migration (@wolfiesch)

# Conflicts:
#	packages/coding-agent/src/modes/components/settings-selector.ts
This commit is contained in:
can1357
2026-06-27 01:40:11 +02:00
25 changed files with 513 additions and 127 deletions
+3
View File
@@ -164,6 +164,9 @@
- Fixed GitLab Duo Agent namespace/project discovery reading the original repo's git remote after a `/move`. The session's working directory is now resolved live (per LLM call) from the `SessionManager` instead of being captured when the agent was constructed, so moving the session re-scopes Duo workspace discovery to the new repository.
- Fixed `omp auth-broker login gitlab-duo-agent` (and `--via`) hanging until timeout: the provider uses GitLab's fixed `vscode://` OAuth redirect, which never reaches the broker's local callback server, and `runLocalLogin` supplied no `onManualCodeInput` fallback. The broker login now offers the same paste-the-redirect-URL prompt the interactive sign-in uses, so credentials can be saved.
### Changed
- Reused shared TUI mouse-routing helpers across fullscreen overlay selectors.
## [16.1.16] - 2026-06-23
+1
View File
@@ -0,0 +1 @@
/work/pi/packages/coding-agent/node_modules
@@ -15,7 +15,7 @@
*/
import * as fs from "node:fs";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
import { type Component, Editor, matchesKey, routeSgrMouseInput, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
import type { KeyId } from "../../config/keybindings";
import type { MessageRenderer } from "../../extensibility/extensions/types";
@@ -413,12 +413,14 @@ export class AgentTranscriptViewer implements Component {
handleInput(data: string): void {
if (data.startsWith("\x1b[<")) {
const event = parseSgrMouse(data);
if (event?.wheel != null) {
this.#scrollView.scroll(event.wheel * 3);
this.#syncFollow();
this.deps.requestRender();
}
routeSgrMouseInput(data, event => {
if (event.wheel !== null) {
this.#scrollView.scroll(event.wheel * 3);
this.#syncFollow();
this.deps.requestRender();
}
return true;
});
return;
}
@@ -23,7 +23,7 @@ import {
Markdown,
type MarkdownTheme,
matchesKey,
parseSgrMouse,
routeSgrMouseInput,
ScrollView,
truncateToWidth,
visibleWidth,
@@ -333,42 +333,42 @@ export class PlanReviewOverlay implements Component {
* the body.
*/
#handleMouse(data: string): boolean {
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 (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(event.row));
return true;
}
if (!event.leftClick) return true;
const optionIndex = this.#optionClickRows.get(event.row);
if (optionIndex !== undefined) {
if (!this.#disabled.has(optionIndex)) {
this.#focus = "actions";
this.#selectedIndex = optionIndex;
this.#confirmSelection();
return routeSgrMouseInput(data, event => {
if (event.wheel !== null) {
// Scroll wheel: three rows per notch.
this.#scrollView.scroll(event.wheel * 3);
return true;
}
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(event.row));
return true;
}
if (!event.leftClick) return true;
const optionIndex = this.#optionClickRows.get(event.row);
if (optionIndex !== undefined) {
if (!this.#disabled.has(optionIndex)) {
this.#focus = "actions";
this.#selectedIndex = optionIndex;
this.#confirmSelection();
}
return true;
}
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(event.row)) {
this.#setFocus("body");
}
return true;
}
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(event.row)) {
this.#setFocus("body");
}
return true;
});
}
/** Set the hovered option from a hit-tested row, ignoring disabled rows and
@@ -4,7 +4,7 @@
* Shows available plugins from all configured marketplaces in a SelectList.
* Selecting a plugin triggers installation. Esc cancels.
*/
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../theme/theme";
import { DynamicBorder } from "./dynamic-border";
@@ -82,6 +82,11 @@ export class PluginSelectorComponent extends Container {
getSelectList(): SelectList {
return this.#selectList;
}
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
const topBorderRows = 1;
this.#selectList.routeMouse(event, line - topBorderRows, col);
}
}
function splitPluginId(id: string): [string, string, "user" | "project" | undefined] | [null, null, null] {
@@ -1,4 +1,4 @@
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../../modes/theme/theme";
import { DynamicBorder } from "./dynamic-border";
@@ -53,4 +53,9 @@ export class QueueModeSelectorComponent extends Container {
getSelectList(): SelectList {
return this.#selectList;
}
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
const topBorderRows = 1;
this.#selectList.routeMouse(event, line - topBorderRows, col);
}
}
@@ -5,8 +5,8 @@ import {
Input,
matchesKey,
padding,
parseSgrMouse,
replaceTabs,
routeSgrMouseInput,
ScrollView,
Spacer,
Text,
@@ -726,15 +726,16 @@ export class SessionSelectorComponent extends Container {
*/
#handleMouse(data: string): void {
if (this.#confirmationDialog) return;
const event = parseSgrMouse(data);
if (!event) return;
if (event.wheel !== null) {
this.#sessionList.handleWheel(event.wheel);
return;
}
if (!event.leftClick || event.row >= this.#footerStart) return;
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
routeSgrMouseInput(data, event => {
if (event.wheel !== null) {
this.#sessionList.handleWheel(event.wheel);
return true;
}
if (!event.leftClick || event.row >= this.#footerStart) return true;
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
return true;
});
}
getSessionList(): SessionList {
@@ -10,8 +10,9 @@ import {
type ImageBudget,
Input,
matchesKey,
parseSgrMouse,
replaceTabs,
routeSelectListMouse,
routeSgrMouseInput,
type SelectItem,
SelectList,
type SettingItem,
@@ -105,7 +106,6 @@ class SelectSubmenu extends Container {
#previewText: Text | null = null;
#previewUpdateRequestId: number = 0;
#selectListLineOffset = 0;
#selectListLineCount = 0;
constructor(
title: string,
@@ -203,7 +203,6 @@ class SelectSubmenu extends Container {
const childLines = child.render(Math.max(1, width));
if (child === this.#selectList) {
this.#selectListLineOffset = lines.length;
this.#selectListLineCount = childLines.length;
}
lines.push(...childLines);
}
@@ -212,20 +211,7 @@ class SelectSubmenu extends Container {
/** 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);
}
routeSelectListMouse(this.#selectList, event, line - this.#selectListLineOffset);
}
handleInput(data: string): void {
@@ -583,12 +569,14 @@ export class SettingsSelectorComponent implements Component {
* activates it (toggle / open submenu).
*/
#handleMouse(data: string): boolean {
const event = parseSgrMouse(data);
if (!event) return false;
return routeSgrMouseInput(data, event => this.#routeMouseEvent(event));
}
#routeMouseEvent(event: SgrMouseEvent): boolean {
const list = this.#searchList ?? this.#currentList;
// row() insets content by two columns (border + space).
const innerCol = event.col - 2;
// row() insets content by the border column plus a space.
const contentColInset = 2;
const innerCol = event.col - contentColInset;
const contentLine = event.row - this.#contentRowStart;
// An open submenu owns the pointer: wheel, hover, and clicks route into
@@ -1,4 +1,4 @@
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../../modes/theme/theme";
import { DynamicBorder } from "./dynamic-border";
@@ -42,4 +42,9 @@ export class ShowImagesSelectorComponent extends Container {
getSelectList(): SelectList {
return this.#selectList;
}
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
const topBorderRows = 1;
this.#selectList.routeMouse(event, line - topBorderRows, col);
}
}
@@ -1,4 +1,4 @@
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../../modes/theme/theme";
import { DynamicBorder } from "./dynamic-border";
@@ -60,4 +60,9 @@ export class ThemeSelectorComponent extends Container {
getSelectList(): SelectList {
return this.#selectList;
}
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
const topBorderRows = 1;
this.#selectList.routeMouse(event, line - topBorderRows, col);
}
}
@@ -1,5 +1,5 @@
import type { Effort } from "@oh-my-pi/pi-ai";
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking";
import { DynamicBorder } from "./dynamic-border";
@@ -49,4 +49,9 @@ export class ThinkingSelectorComponent extends Container {
getSelectList(): SelectList {
return this.#selectList;
}
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
const topBorderRows = 1;
this.#selectList.routeMouse(event, line - topBorderRows, col);
}
}
@@ -1,4 +1,4 @@
import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme";
import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
@@ -65,18 +65,7 @@ class GlyphSceneController implements SetupSceneController {
/** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
if (this.#committing) return;
if (event.wheel !== null) {
this.#selectList.handleWheel(event.wheel);
return;
}
const index = this.#selectList.hitTest(line - this.#listRowStart);
if (event.motion) {
this.#selectList.setHoverIndex(index ?? null);
return;
}
if (event.leftClick && index !== undefined) {
this.#selectList.clickItem(index);
}
routeSelectListMouse(this.#selectList, event, line - this.#listRowStart);
}
render(width: number): readonly string[] {
@@ -74,7 +74,8 @@ class ProvidersSceneController implements SetupSceneController {
return;
}
if (event.motion) this.#tabBar.setHoverTab(null);
const bodyLine = line - this.#tabRowCount - 1;
const spacerRowsAfterTabs = 1;
const bodyLine = line - this.#tabRowCount - spacerRowsAfterTabs;
if (tab.routeMouse) {
tab.routeMouse(event, bodyLine, col);
return;
@@ -1,5 +1,6 @@
import {
padding,
routeSelectListMouse,
type SelectItem,
SelectList,
type SgrMouseEvent,
@@ -128,18 +129,11 @@ class ThemeSceneController implements SetupSceneController {
/** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
if (event.wheel !== null) {
this.#selectList.handleWheel(event.wheel);
return;
}
const index = this.#listRowStart >= 0 ? this.#selectList.hitTest(line - this.#listRowStart) : undefined;
if (event.motion) {
this.#selectList.setHoverIndex(index ?? null);
return;
}
if (event.leftClick && index !== undefined) {
this.#selectList.clickItem(index);
}
// Mirror the pre-helper flow: wheel/motion are always processed, but a
// hidden list (#listRowStart < 0, e.g. while loading all themes) must
// never hit-test a row — route through a line that resolves to undefined.
const listLine = this.#listRowStart >= 0 ? line - this.#listRowStart : Number.NEGATIVE_INFINITY;
routeSelectListMouse(this.#selectList, event, listLine);
}
render(width: number): readonly string[] {
@@ -1,4 +1,10 @@
import { type SelectItem, SelectList, type SgrMouseEvent, truncateToWidth } from "@oh-my-pi/pi-tui";
import {
routeSelectListMouse,
type SelectItem,
SelectList,
type SgrMouseEvent,
truncateToWidth,
} from "@oh-my-pi/pi-tui";
import { SETTINGS_SCHEMA } from "../../../config/settings-schema";
import { getSearchProvider, setPreferredSearchProvider } from "../../../web/search/provider";
import { isSearchProviderPreference, type SearchProviderId } from "../../../web/search/types";
@@ -59,18 +65,7 @@ export class WebSearchTab implements SetupTab {
/** Wheel moves the highlight; hover lights the row under the pointer; click confirms it. */
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
if (event.wheel !== null) {
this.#list.handleWheel(event.wheel);
return;
}
const index = this.#list.hitTest(line - this.#listRowStart);
if (event.motion) {
this.#list.setHoverIndex(index ?? null);
return;
}
if (event.leftClick && index !== undefined) {
this.#list.clickItem(index);
}
routeSelectListMouse(this.#list, event, line - this.#listRowStart);
}
invalidate(): void {
@@ -3,7 +3,8 @@ import {
matchesKey,
type OverlayFocusOwner,
padding,
parseSgrMouse,
routeSgrMouseInput,
type SgrMouseEvent,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
@@ -104,7 +105,9 @@ export class SetupWizardComponent implements Component, OverlayFocusOwner {
handleInput(data: string): void {
if (this.#phase === "done") return;
if (data.startsWith("\x1b[<")) {
this.#handleMouse(data);
routeSgrMouseInput(data, event => {
this.#routeMouseEvent(event);
});
return;
}
if (matchesKey(data, "ctrl+c")) {
@@ -146,9 +149,7 @@ export class SetupWizardComponent implements Component, OverlayFocusOwner {
* advances the splash/outro like Enter. Raw reports never reach scene
* keyboard input.
*/
#handleMouse(data: string): void {
const event = parseSgrMouse(data);
if (!event) return;
#routeMouseEvent(event: SgrMouseEvent): void {
if (this.#phase === "splash" || this.#phase === "outro") {
if (!event.leftClick) return;
if (this.#phase === "splash") this.#beginScene();
@@ -0,0 +1,52 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { ShowImagesSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/show-images-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { SgrMouseEvent } from "@oh-my-pi/pi-tui";
beforeAll(async () => {
await initTheme();
});
function leftClick(line: number): SgrMouseEvent {
return { button: 0, col: 0, row: line, release: false, wheel: null, motion: false, leftClick: true };
}
/**
* The wrapper mounts a single-line top DynamicBorder before its SelectList, so
* routed component-local lines are offset by one. Regression guard for the
* off-by-one that would let a top-border click select the first row.
*/
describe("ShowImagesSelectorComponent.routeMouse offset", () => {
it("ignores a click on the top border row (line 0)", () => {
let selected: boolean | undefined;
const component = new ShowImagesSelectorComponent(
true,
value => {
selected = value;
},
() => {},
);
component.render(80);
component.routeMouse(leftClick(0), 0, 0);
expect(selected).toBeUndefined();
});
it("selects the first item when the row below the border is clicked (line 1)", () => {
let selected: boolean | undefined;
const component = new ShowImagesSelectorComponent(
true,
value => {
selected = value;
},
() => {},
);
component.render(80);
component.routeMouse(leftClick(1), 1, 0);
// First SelectList row is "Yes" → true.
expect(selected).toBe(true);
});
});
@@ -0,0 +1,105 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { PluginSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/plugin-selector";
import { QueueModeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/queue-mode-selector";
import { ThemeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/theme-selector";
import { ThinkingSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/thinking-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { SgrMouseEvent } from "@oh-my-pi/pi-tui";
beforeAll(async () => {
await initTheme();
});
function leftClick(line: number): SgrMouseEvent {
return { button: 0, col: 0, row: line, release: false, wheel: null, motion: false, leftClick: true };
}
/**
* Every wrapper mounts a single-line top DynamicBorder before its SelectList,
* so routed component-local lines are offset by one. These guard the
* off-by-one that would let a top-border click select the first row. Each case
* asserts line 0 (border) is inert and line 1 (first list row) confirms.
*/
describe("inline-picker wrapper routeMouse offset", () => {
it("ThemeSelectorComponent ignores the border row and selects the first theme below it", () => {
let selected: string | undefined;
const component = new ThemeSelectorComponent(
"alpha",
["alpha", "beta"],
value => {
selected = value;
},
() => {},
() => {},
);
component.render(80);
component.routeMouse(leftClick(0), 0, 0);
expect(selected).toBeUndefined();
component.routeMouse(leftClick(1), 1, 0);
expect(selected).toBe("alpha");
});
it("ThinkingSelectorComponent ignores the border row and selects the first level below it", () => {
let selected: Effort | undefined;
const levels = [Effort.Low, Effort.High];
const component = new ThinkingSelectorComponent(
Effort.Low,
levels,
value => {
selected = value;
},
() => {},
);
component.render(80);
component.routeMouse(leftClick(0), 0, 0);
expect(selected).toBeUndefined();
component.routeMouse(leftClick(1), 1, 0);
expect(selected).toBe(Effort.Low);
});
it("QueueModeSelectorComponent ignores the border row and selects the first mode below it", () => {
let selected: "all" | "one-at-a-time" | undefined;
const component = new QueueModeSelectorComponent(
"all",
value => {
selected = value;
},
() => {},
);
component.render(80);
component.routeMouse(leftClick(0), 0, 0);
expect(selected).toBeUndefined();
// First SelectList row is "one-at-a-time" regardless of the preselected mode.
component.routeMouse(leftClick(1), 1, 0);
expect(selected).toBe("one-at-a-time");
});
it("PluginSelectorComponent ignores the border row and selects the first plugin below it", () => {
let selectedName: string | undefined;
const component = new PluginSelectorComponent(
1,
[{ plugin: { name: "alpha", description: "first" }, marketplace: "shop" }],
new Set<string>(),
{
onSelect: name => {
selectedName = name;
},
onCancel: () => {},
},
);
component.render(80);
component.routeMouse(leftClick(0), 0, 0);
expect(selectedName).toBeUndefined();
component.routeMouse(leftClick(1), 1, 0);
expect(selectedName).toBe("alpha");
});
});
+1
View File
@@ -0,0 +1 @@
/work/pi/packages/hashline/node_modules
+3
View File
@@ -49,6 +49,9 @@
- Fixed `@`-path autocomplete failing on Windows for paths outside the cwd. Windows absolute paths (e.g. `C:\\Users\\...`) were not detected as absolute — only `/` was checked — so they were incorrectly joined with the base directory, producing invalid search paths and empty suggestions. Path-join calls also introduced backslashes into suggestion values, breaking round-trip insertion. Absolute path detection now uses `path.isAbsolute()` (handles drive letters) and suggestion paths are normalized to forward slashes (valid on all platforms).
- Fixed settings rows crashing native text truncation when a malformed config value reaches the renderer as a non-string ([#3338](https://github.com/can1357/oh-my-pi/issues/3338)).
- Fixed desktop notifications being silently lost under tmux on the common stack of tmux + kitty/ghostty/wezterm/iTerm2. `TERMINAL_ID` resolves to the inner terminal (whose markers leak into the tmux session env), which maps to `NotifyProtocol.Osc9` / `NotifyProtocol.Osc99`, and `sendNotification()` wrote that raw OSC straight to stdout — tmux dropped it on the floor and `monitor-bell` / `monitor-activity` never fired, so a backgrounded omp pane had no way to flag completion or `ask` blockage. Under `TMUX`, OSC-protocol notifications are now wrapped in tmux's `\x1bPtmux;…\x1b\\` DCS passthrough envelope (so users with `set -g allow-passthrough on` still get the real toast on the outer terminal) and followed by a `\x07` BEL (so `set -g monitor-bell on` reliably flags the window otherwise). The OSC 99 capability probe in `terminal.ts` is wrapped the same way so rich notifications keep working across tmux. `NotifyProtocol.Bell` paths are unchanged. ([#3395](https://github.com/can1357/oh-my-pi/issues/3395))
### Added
- Added shared SGR mouse input routing helpers and `SelectList.routeMouse()` support for fullscreen overlay hit-testing.
## [16.1.10] - 2026-06-21
+6 -1
View File
@@ -2,6 +2,7 @@ import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
import { fuzzyFilter } from "../fuzzy";
import { getKeybindings } from "../keybindings";
import { extractPrintableText } from "../keys";
import { type MouseRoutable, routeSelectListMouse, type SgrMouseEvent } from "../mouse";
import type { SymbolTheme } from "../symbols";
import type { Component } from "../tui";
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
@@ -80,7 +81,7 @@ type SelectItemLayout =
spacing: "";
};
export class SelectList implements Component {
export class SelectList implements Component, MouseRoutable {
#filteredItems: ReadonlyArray<SelectItem>;
#filterQuery = "";
#selectedIndex: number = 0;
@@ -139,6 +140,10 @@ export class SelectList implements Component {
this.onSelect?.(item);
}
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
routeSelectListMouse(this, event, line);
}
invalidate(): void {
// No cached state to invalidate currently
}
+50
View File
@@ -44,6 +44,56 @@ export function parseSgrMouse(data: string): SgrMouseEvent | null {
return { button, col, row, release, wheel, motion, leftClick };
}
/** Handler invoked with a decoded SGR event; returning `false` reports unhandled. */
export type SgrMouseHandler = (event: SgrMouseEvent) => boolean | undefined;
/**
* Decode an SGR mouse report and forward it to `handler`. Returns `false` when
* `data` is not an SGR mouse report (or fails to parse), so callers can fall
* through to other input handling. Centralizes the repeated
* `data.startsWith("\x1b[<")` + `parseSgrMouse()` pattern.
*/
export function routeSgrMouseInput(data: string, handler: SgrMouseHandler): boolean {
if (!data.startsWith("\x1b[<")) return false;
const event = parseSgrMouse(data);
if (!event) return false;
return handler(event) !== false;
}
/**
* Structural view of a SelectList-like target for mouse routing. Declared here
* (rather than importing the component) to keep this core module free of any
* component-to-core import cycle.
*/
export interface SelectListMouseTarget {
handleWheel(delta: -1 | 1): void;
hitTest(line: number): number | undefined;
setHoverIndex(index: number | null): void;
clickItem(index: number): void;
}
/**
* Route a decoded mouse event against a SelectList-like target at the given
* 0-based frame-local `line`. Centralizes the repeated wheel/hit-test/hover/
* click pattern. Returns `true` when the event was consumed.
*/
export function routeSelectListMouse(target: SelectListMouseTarget, event: SgrMouseEvent, line: number): boolean {
if (event.wheel !== null) {
target.handleWheel(event.wheel);
return true;
}
const index = target.hitTest(line);
if (event.motion) {
target.setHoverIndex(index ?? null);
return true;
}
if (event.leftClick && index !== undefined) {
target.clickItem(index);
return true;
}
return false;
}
/**
* Implemented by components that accept routed mouse events at frame-local
* coordinates. Hosts translate screen coordinates to the component's own
@@ -76,6 +76,9 @@ describe("emergencyTerminalRestore alt-screen gating", () => {
const restored = writes.join("");
expect(restored).not.toContain("\x1b[?1049l");
expect(restored).toContain("\x1b[?1006l");
expect(restored).toContain("\x1b[?1003l");
expect(restored).toContain("\x1b[?1000l");
// Still performs the blind restore itself (cursor visibility proves the branch ran).
expect(restored).toContain("\x1b[?25h");
});
@@ -87,7 +90,11 @@ describe("emergencyTerminalRestore alt-screen gating", () => {
writes.length = 0;
emergencyTerminalRestore();
expect(writes.join("")).toContain("\x1b[?1049l");
const firstRestore = writes.join("");
expect(firstRestore).toContain("\x1b[?1049l");
expect(firstRestore).toContain("\x1b[?1006l");
expect(firstRestore).toContain("\x1b[?1003l");
expect(firstRestore).toContain("\x1b[?1000l");
// State was consumed: a second restore must not leave the (now main) buffer again.
writes.length = 0;
@@ -99,12 +106,20 @@ describe("emergencyTerminalRestore alt-screen gating", () => {
const inactive = startCapturedTerminal();
inactive.writes.length = 0;
emergencyTerminalRestore(); // activeTerminal set, alt screen never entered
expect(inactive.writes.join("")).not.toContain("\x1b[?1049l");
const inactiveRestore = inactive.writes.join("");
expect(inactiveRestore).not.toContain("\x1b[?1049l");
expect(inactiveRestore).toContain("\x1b[?1006l");
expect(inactiveRestore).toContain("\x1b[?1003l");
expect(inactiveRestore).toContain("\x1b[?1000l");
const active = startCapturedTerminal();
setAltScreenActive(true);
active.writes.length = 0;
emergencyTerminalRestore();
expect(active.writes.join("")).toContain("\x1b[?1049l");
const activeRestore = active.writes.join("");
expect(activeRestore).toContain("\x1b[?1049l");
expect(activeRestore).toContain("\x1b[?1006l");
expect(activeRestore).toContain("\x1b[?1003l");
expect(activeRestore).toContain("\x1b[?1000l");
});
});
+91 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "bun:test";
import { parseSgrMouse } from "@oh-my-pi/pi-tui/mouse";
import {
parseSgrMouse,
routeSelectListMouse,
routeSgrMouseInput,
type SelectListMouseTarget,
type SgrMouseEvent,
} from "@oh-my-pi/pi-tui/mouse";
describe("parseSgrMouse", () => {
it("returns null for non-mouse input", () => {
@@ -40,3 +46,87 @@ describe("parseSgrMouse", () => {
expect(event?.wheel).toBeNull();
});
});
describe("routeSgrMouseInput", () => {
it("returns false and does not call the handler for non-mouse input", () => {
let called = false;
const handled = routeSgrMouseInput("a", () => {
called = true;
return true;
});
expect(handled).toBe(false);
expect(called).toBe(false);
});
it("decodes and forwards an SGR mouse report", () => {
let received: SgrMouseEvent | null = null;
const handled = routeSgrMouseInput("\x1b[<0;2;3M", event => {
received = event;
return true;
});
expect(handled).toBe(true);
if (received === null) throw new Error("expected routeSgrMouseInput to forward an event");
const event: SgrMouseEvent = received;
expect(event.row).toBe(2);
expect(event.col).toBe(1);
expect(event.leftClick).toBe(true);
});
});
describe("routeSelectListMouse", () => {
function makeTarget(hit: number | undefined) {
const calls: string[] = [];
const target: SelectListMouseTarget = {
handleWheel: delta => calls.push(`wheel:${delta}`),
hitTest: () => hit,
setHoverIndex: index => calls.push(`hover:${index}`),
clickItem: index => calls.push(`click:${index}`),
};
return { target, calls };
}
const baseEvent: SgrMouseEvent = {
button: 0,
col: 0,
row: 0,
release: false,
wheel: null,
motion: false,
leftClick: false,
};
it("forwards wheel notches", () => {
const { target, calls } = makeTarget(undefined);
const handled = routeSelectListMouse(target, { ...baseEvent, wheel: 1 }, 0);
expect(handled).toBe(true);
expect(calls).toEqual(["wheel:1"]);
});
it("hovers the hit-tested row on motion", () => {
const { target, calls } = makeTarget(4);
const handled = routeSelectListMouse(target, { ...baseEvent, motion: true }, 0);
expect(handled).toBe(true);
expect(calls).toEqual(["hover:4"]);
});
it("clears hover when motion misses a row", () => {
const { target, calls } = makeTarget(undefined);
const handled = routeSelectListMouse(target, { ...baseEvent, motion: true }, 0);
expect(handled).toBe(true);
expect(calls).toEqual(["hover:null"]);
});
it("clicks the hit-tested row", () => {
const { target, calls } = makeTarget(2);
const handled = routeSelectListMouse(target, { ...baseEvent, leftClick: true }, 0);
expect(handled).toBe(true);
expect(calls).toEqual(["click:2"]);
});
it("ignores release events", () => {
const { target, calls } = makeTarget(2);
const handled = routeSelectListMouse(target, { ...baseEvent, release: true }, 0);
expect(handled).toBe(false);
expect(calls).toEqual([]);
});
});
+65
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { SelectList } from "@oh-my-pi/pi-tui/components/select-list";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings";
import type { SgrMouseEvent } from "@oh-my-pi/pi-tui/mouse";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
const testTheme = {
@@ -373,3 +374,67 @@ describe("SelectList", () => {
});
});
});
describe("SelectList.routeMouse", () => {
const hoverTheme = {
...testTheme,
hovered: (text: string) => `<hover>${text}</hover>`,
selectedText: (text: string) => `<selected>${text}</selected>`,
};
const baseEvent: SgrMouseEvent = {
button: 0,
col: 0,
row: 0,
release: false,
wheel: null,
motion: false,
leftClick: false,
};
function makeList() {
const items = [
{ value: "a", label: "a" },
{ value: "b", label: "b" },
{ value: "c", label: "c" },
];
return new SelectList(items, 5, hoverTheme);
}
it("advances selection on a wheel notch", () => {
const list = makeList();
let changed: string | undefined;
list.onSelectionChange = item => {
changed = item.value;
};
list.render(80);
list.routeMouse({ ...baseEvent, wheel: 1 }, 0, 0);
expect(changed).toBe("b");
});
it("hovers the row under the pointer on motion", () => {
const list = makeList();
list.render(80);
list.routeMouse({ ...baseEvent, motion: true }, 1, 0);
const rendered = list.render(80).join("\n");
expect(rendered).toContain("<hover>");
expect(rendered).not.toContain("<hover><selected>");
});
it("confirms the clicked row", () => {
const list = makeList();
let selected: string | undefined;
list.onSelect = item => {
selected = item.value;
};
list.render(80);
list.routeMouse({ ...baseEvent, leftClick: true }, 2, 0);
expect(selected).toBe("c");
});
});