feat(tui): add reusable mouse interaction support

This commit is contained in:
Mario Zechner 2026-08-14 15:18:59 +02:00
parent 19fbf94110
commit 71026970a0
20 changed files with 1252 additions and 57 deletions

View file

@ -12,6 +12,7 @@
### Added
- Added per-block fullscreen mouse expansion for thinking sections and tool results, while preserving drag selection and link activation.
- Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`.
- Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`.
- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint.

View file

@ -14,6 +14,7 @@ All components implement:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
wantsKeyRelease?: boolean;
invalidate(): void;
}
@ -23,6 +24,7 @@ interface Component {
|--------|-------------|
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `handleMouse?(event)` | Receive normalized pointer input in fullscreen mode. |
| `wantsKeyRelease?` | If true, component receives key release events (Kitty protocol). Default: false. |
| `invalidate()` | Clear cached render state. Called on theme changes. |
@ -307,6 +309,22 @@ handleInput(data: string) {
- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
## Mouse Input
Fullscreen mode routes normalized press, release, click, move, drag, and wheel events to components and overlays. Return `{ handled: true }` to suppress default behavior, `capture: true` to retain drag/release ownership, `focus: true` to request keyboard focus, and `render: true` when a hover or release visibly changes the component. Press, click, drag, and wheel render by default; no-op move/release events do not.
```typescript
import { MouseRegion } from "@earendil-works/pi-tui";
const clickable = new MouseRegion(content, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
expanded = !expanded;
return { handled: true };
});
```
Unhandled wheel input scrolls the nearest `ScrollView`; unhandled primary-button drags retain transcript selection. OSC 8 links take precedence over parent click regions. `Input`, `Editor`, `SelectList`, and `SettingsList` include fullscreen mouse behavior. Regular mode does not capture mouse input because the terminal owns its scrollback.
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter.

View file

@ -1,5 +1,5 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import { Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
@ -21,6 +21,7 @@ export class AssistantMessageComponent extends Container {
private lastMessage?: AssistantMessage;
private hasToolCalls = false;
private isStreaming = false;
private thinkingVisibilityOverrides = new Map<number, boolean>();
constructor(
message?: AssistantMessage,
@ -56,6 +57,7 @@ export class AssistantMessageComponent extends Container {
setHideThinkingBlock(hide: boolean): void {
this.hideThinkingBlock = hide;
this.thinkingVisibilityOverrides.clear();
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
@ -102,6 +104,7 @@ export class AssistantMessageComponent extends Container {
}
// Render content in order
let thinkingRunIndex = 0;
for (let i = 0; i < message.content.length; i++) {
const content = message.content[i];
if (content.type === "text" && content.text.trim()) {
@ -136,15 +139,11 @@ export class AssistantMessageComponent extends Container {
.slice(i + 1)
.some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
if (this.hideThinkingBlock) {
// Show one static label for each run of thinking blocks when hidden.
this.contentContainer.addChild(
new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0),
);
} else {
// Render each run of thinking blocks as one Markdown section.
this.contentContainer.addChild(
new Markdown(
const runIndex = thinkingRunIndex++;
const hidden = this.thinkingVisibilityOverrides.get(runIndex) ?? this.hideThinkingBlock;
const thinkingComponent = hidden
? new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0)
: new Markdown(
thinkingBlocks.join("\n\n"),
this.outputPad,
0,
@ -160,9 +159,15 @@ export class AssistantMessageComponent extends Container {
this.markdownTransformers,
),
},
),
);
}
);
this.contentContainer.addChild(
new MouseRegion(thinkingComponent, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
this.thinkingVisibilityOverrides.set(runIndex, !hidden);
if (this.lastMessage) this.updateContent(this.lastMessage);
return { handled: true };
}),
);
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
}

View file

@ -1,4 +1,15 @@
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import {
Box,
type Component,
Container,
getCapabilities,
Image,
MouseRegion,
Spacer,
Text,
type TUI,
type TuiMouseEvent,
} from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
@ -16,7 +27,9 @@ export interface ToolExecutionOptions {
export class ToolExecutionComponent extends Container {
private contentBox: Box;
private contentText: Text;
private contentTextRegion: MouseRegion;
private selfRenderContainer: Container;
private selfRenderHeight = 0;
private callRendererComponent?: Component;
private resultRendererComponent?: Component;
private rendererState: any = {};
@ -70,12 +83,13 @@ export class ToolExecutionComponent extends Container {
// contentText is reserved for generic fallback rendering when no tool definition exists.
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentTextRegion = this.createResultRegion(this.contentText);
this.selfRenderContainer = new Container();
if (this.hasRendererDefinition()) {
this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox);
} else {
this.addChild(this.contentText);
this.addChild(this.contentTextRegion);
}
this.updateDisplay();
@ -155,6 +169,14 @@ export class ToolExecutionComponent extends Container {
return new Text(text, 0, 0);
}
private createResultRegion(component: Component): MouseRegion {
return new MouseRegion(component, (event) => {
if (!this.result || event.type !== "click" || event.button !== "left") return undefined;
this.setExpanded(!this.expanded);
return { handled: true };
});
}
updateArgs(args: any): void {
this.args = args;
this.updateDisplay();
@ -236,6 +258,7 @@ export class ToolExecutionComponent extends Container {
if (this.hasRendererDefinition() && this.getRenderShell() === "self") {
const contentLines = this.selfRenderContainer.render(width);
this.selfRenderHeight = contentLines.length;
if (contentLines.length === 0 && this.imageComponents.length === 0) {
return [];
}
@ -261,6 +284,16 @@ export class ToolExecutionComponent extends Container {
return super.render(width);
}
override handleMouse(event: TuiMouseEvent): ReturnType<Container["handleMouse"]> {
if (!this.hasRendererDefinition() || this.getRenderShell() !== "self") return super.handleMouse(event);
if (event.y <= 0 || event.y > this.selfRenderHeight) return undefined;
return this.selfRenderContainer.handleMouse({
...event,
y: event.y - 1,
height: this.selfRenderHeight,
});
}
private updateDisplay(): void {
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
@ -279,17 +312,17 @@ export class ToolExecutionComponent extends Container {
const callRenderer = this.getCallRenderer();
if (!callRenderer) {
renderContainer.addChild(this.createCallFallback());
renderContainer.addChild(this.createResultRegion(this.createCallFallback()));
hasContent = true;
} else {
try {
const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
this.callRendererComponent = component;
renderContainer.addChild(component);
renderContainer.addChild(this.createResultRegion(component));
hasContent = true;
} catch {
this.callRendererComponent = undefined;
renderContainer.addChild(this.createCallFallback());
renderContainer.addChild(this.createResultRegion(this.createCallFallback()));
hasContent = true;
}
}
@ -299,7 +332,7 @@ export class ToolExecutionComponent extends Container {
if (!resultRenderer) {
const component = this.createResultFallback();
if (component) {
renderContainer.addChild(component);
renderContainer.addChild(this.createResultRegion(component));
hasContent = true;
}
} else {
@ -311,13 +344,13 @@ export class ToolExecutionComponent extends Container {
this.getRenderContext(this.resultRendererComponent),
);
this.resultRendererComponent = component;
renderContainer.addChild(component);
renderContainer.addChild(this.createResultRegion(component));
hasContent = true;
} catch {
this.resultRendererComponent = undefined;
const component = this.createResultFallback();
if (component) {
renderContainer.addChild(component);
renderContainer.addChild(this.createResultRegion(component));
hasContent = true;
}
}

View file

@ -1,4 +1,5 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts";
import { UserMessageComponent } from "../src/modes/interactive/components/user-message.ts";
@ -91,6 +92,41 @@ describe("AssistantMessageComponent", () => {
expect(rendered).toContain("answer");
});
test("collapses individual thinking runs when clicked", () => {
initTheme("dark");
const component = new AssistantMessageComponent(
createAssistantMessage([
{ type: "thinking", thinking: "first reasoning" },
{ type: "text", text: "answer" },
{ type: "thinking", thinking: "second reasoning" },
]),
);
const width = 80;
const lines = component.render(width);
const firstThinkingRow = lines.findIndex((line) => stripAnsi(line).includes("first reasoning"));
expect(firstThinkingRow).toBeGreaterThanOrEqual(0);
const event: TuiMouseEvent = {
type: "click",
button: "left",
x: 1,
y: firstThinkingRow,
screenX: 1,
screenY: firstThinkingRow,
width,
height: lines.length,
shift: false,
alt: false,
ctrl: false,
clickCount: 1,
};
expect(component.handleMouse(event)?.handled).toBe(true);
const collapsed = stripAnsi(component.render(width).join("\n"));
expect(collapsed).not.toContain("first reasoning");
expect(collapsed).toContain("Thinking...");
expect(collapsed).toContain("second reasoning");
});
test("uses configured output padding for text and thinking", () => {
initTheme("dark");

View file

@ -1,5 +1,5 @@
import { join, resolve } from "node:path";
import { Text, type TUI } from "@earendil-works/pi-tui";
import { Text, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { beforeAll, describe, expect, test } from "vitest";
import { getReadmePath } from "../src/config.ts";
@ -429,6 +429,42 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain(theme.fg("toolOutput", error));
});
test("expands a collapsed tool result when clicked", () => {
const component = new ToolExecutionComponent(
"read",
"tool-click-expand",
{ path: "notes.txt" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
process.cwd(),
);
component.updateResult(
{ content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false },
false,
);
const width = 120;
const lines = component.render(width);
const resultRow = lines.findIndex((line) => stripAnsi(line).includes("notes.txt"));
expect(resultRow).toBeGreaterThanOrEqual(0);
const event: TuiMouseEvent = {
type: "click",
button: "left",
x: 2,
y: resultRow,
screenX: 2,
screenY: resultRow,
width,
height: lines.length,
shift: false,
alt: false,
ctrl: false,
clickCount: 1,
};
expect(component.handleMouse(event)?.handled).toBe(true);
expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content");
});
test("collapses ordinary read results until expanded", () => {
const component = new ToolExecutionComponent(
"read",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added fullscreen component mouse dispatch with hit-testing, bubbling through `Container` and `Box`, pointer capture, focus transfer, overlays, render control, and the `MouseRegion` wrapper; `Input`, `Editor`, `SelectList`, and `SettingsList` now support pointer interaction.
## [0.84.2] - 2026-08-14
### Added

View file

@ -11,7 +11,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
- **Component-based**: Simple Component interface with render() method
- **Theme Support**: Components accept theme interfaces for customizable styling
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container, VStack, HStack, ScrollView
- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
- **Autocomplete Support**: File paths and slash commands
@ -211,6 +211,7 @@ All components implement:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
invalidate?(): void;
}
```
@ -219,10 +220,50 @@ interface Component {
|--------|-------------|
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
| `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. |
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
### Mouse Input
`TuiAltScreen` normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local `x`/`y`, absolute `screenX`/`screenY`, bounds, button, modifiers, click count, and wheel delta. `TuiMainScreen` does not capture mouse input because the terminal owns its scrollback.
```typescript
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type === "click" && event.button === "left") {
this.expanded = !this.expanded;
return { handled: true };
}
if (event.type === "press" && event.button === "left") {
return { handled: true, capture: true, focus: true };
}
if (event.type === "drag") {
this.updateFromPointer(event.x, event.y);
return { handled: true, render: true };
}
return undefined;
}
```
Returning `handled` suppresses renderer-level fallback behavior. `capture` keeps subsequent drag and release events routed to the same component. `focus` requests keyboard focus. The optional `render` flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set `render: true` for a hover state that visibly changed, or `render: false` for a handled no-op. Render requests are coalesced and terminal output remains differential.
Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest `ScrollView` and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.
Use `MouseRegion` to add mouse behavior without changing a component's rendering:
```typescript
const collapsible = new MouseRegion(content, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
expanded = !expanded;
return { handled: true };
});
```
`Container` and `Box` route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit `VStack`, `HStack`, and `ScrollView` layouts use the alternate-screen layout frame directly.
### Focusable Interface (IME Support)
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
@ -339,6 +380,8 @@ input.setValue("initial");
input.getValue();
```
Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.
**Key Bindings:**
- `Enter` - Submit
- `Ctrl+A` / `Ctrl+E` - Line start/end
@ -374,6 +417,7 @@ editor.getPaddingX(); // Get current padding
```
**Features:**
- Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
- Multi-line editing with word wrap
- Slash command autocomplete (type `/`)
- File path autocomplete (press `Tab`)
@ -514,6 +558,8 @@ list.setFilter("opt"); // Filter items
```
**Controls:**
- Mouse move/wheel: Highlight rows in alternate-screen mode
- Click: Select a row
- Arrow keys: Navigate
- Enter: Select
- Escape: Cancel
@ -554,6 +600,8 @@ settings.updateValue("theme", "light");
```
**Controls:**
- Mouse move/wheel: Highlight rows in alternate-screen mode
- Click: Activate a row
- Arrow keys: Navigate
- Enter/Space: Activate (cycle value or open submenu)
- Escape: Cancel

View file

@ -1,4 +1,4 @@
import type { Component } from "../tui.ts";
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
type RenderCache = {
@ -19,6 +19,7 @@ export class Box implements Component {
// Cache for rendered output
private cache?: RenderCache;
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX;
@ -71,6 +72,32 @@ export class Box implements Component {
}
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
const contentWidth = Math.max(1, event.width - this.paddingX * 2);
const contentY = event.y - this.paddingY;
const contentX = event.x - this.paddingX;
if (contentY < 0 || contentX < 0 || contentX >= contentWidth) return undefined;
const mouseChildren =
this.mouseLayout?.width === contentWidth
? this.mouseLayout.children
: this.children.map((component) => ({ component, height: component.render(contentWidth).length }));
let childY = 0;
for (const { component: child, height: childHeight } of mouseChildren) {
if (contentY >= childY && contentY < childY + childHeight) {
return dispatchMouseEvent(child, {
...event,
x: contentX,
y: contentY - childY,
width: contentWidth,
height: childHeight,
});
}
childY += childHeight;
}
return undefined;
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
@ -81,12 +108,15 @@ export class Box implements Component {
// Render all children
const childLines: string[] = [];
const mouseChildren: Array<{ component: Component; height: number }> = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
mouseChildren.push({ component: child, height: lines.length });
for (const line of lines) {
childLines.push(leftPad + line);
}
}
this.mouseLayout = { width: contentWidth, children: mouseChildren };
if (childLines.length === 0) {
return [];

View file

@ -2,7 +2,14 @@ import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocompl
import { getKeybindings } from "../keybindings.ts";
import { decodePrintableKey, matchesKey } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
import {
type Component,
CURSOR_MARKER,
type Focusable,
type TUI,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import {
cjkBreakRegex,
@ -281,8 +288,10 @@ export class Editor implements Component, Focusable {
private theme: EditorTheme;
private paddingX: number = 0;
// Store last render width for cursor navigation
// Store last render geometry for cursor navigation and mouse hit-testing.
private lastWidth: number = 80;
private renderedVisibleLineCount = 1;
private renderedAutocompleteHeight = 0;
// Vertical scrolling support
private scrollOffset: number = 0;
@ -517,6 +526,7 @@ export class Editor implements Component, Focusable {
// Get visible lines slice
const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
this.renderedVisibleLineCount = visibleLines.length;
const result: string[] = [];
const leftPadding = " ".repeat(paddingX);
@ -588,8 +598,10 @@ export class Editor implements Component, Focusable {
}
// Add autocomplete list if active
this.renderedAutocompleteHeight = 0;
if (this.autocompleteState && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(contentWidth);
this.renderedAutocompleteHeight = autocompleteResult.length;
for (const line of autocompleteResult) {
const lineWidth = visibleWidth(line);
const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
@ -600,6 +612,65 @@ export class Editor implements Component, Focusable {
return result;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
const autocompleteStartRow = this.renderedVisibleLineCount + 2;
if (
this.autocompleteState &&
this.autocompleteList &&
event.y >= autocompleteStartRow &&
event.y < autocompleteStartRow + this.renderedAutocompleteHeight
) {
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
const paddingX = Math.min(this.paddingX, maxPadding);
const contentWidth = Math.max(1, event.width - paddingX * 2);
const result = this.autocompleteList.handleMouse?.({
...event,
x: event.x - paddingX,
y: event.y - autocompleteStartRow,
width: contentWidth,
height: this.renderedAutocompleteHeight,
});
return result ? { ...result, focus: true } : undefined;
}
if (event.type !== "press" || event.button !== "left") return undefined;
if (event.y <= 0 || event.y > this.renderedVisibleLineCount) return { handled: true, focus: true };
const visualLines = this.buildVisualLineMap(this.lastWidth);
const visualLineIndex = this.scrollOffset + event.y - 1;
const visualLine = visualLines[visualLineIndex];
if (!visualLine) return { handled: true, focus: true };
const logicalLine = this.state.lines[visualLine.logicalLine] ?? "";
const chunkEnd = visualLine.startCol + visualLine.length;
const chunk = logicalLine.slice(visualLine.startCol, chunkEnd);
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
const paddingX = Math.min(this.paddingX, maxPadding);
const targetColumn = Math.max(0, event.x - paddingX);
let visibleColumn = 0;
let targetIndex = chunk.length;
let lastGraphemeIndex = 0;
for (const grapheme of this.segment(chunk, "grapheme")) {
const nextColumn = visibleColumn + visibleWidth(grapheme.segment);
lastGraphemeIndex = grapheme.index;
if (targetColumn < nextColumn) {
targetIndex = grapheme.index;
break;
}
visibleColumn = nextColumn;
}
const isLastSegment =
visualLineIndex === visualLines.length - 1 ||
visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
if (!isLastSegment && targetIndex === chunk.length && chunk.length > 0) targetIndex = lastGraphemeIndex;
this.state.cursorLine = visualLine.logicalLine;
this.setCursorCol(visualLine.startCol + targetIndex);
this.lastAction = null;
this.exitHistoryBrowsing();
if (this.autocompleteState) this.updateAutocomplete();
return { handled: true, focus: true };
}
handleInput(data: string): void {
const kb = getKeybindings();
@ -2146,7 +2217,25 @@ export class Editor implements Component, Focusable {
items: Array<{ value: string; label: string; description?: string }>,
): SelectList {
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
const list = new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
list.onSelect = (selected) => {
if (!this.autocompleteProvider) return;
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
this.onChange?.(this.getText());
};
return list;
}
private tryTriggerAutocomplete(explicitTab: boolean = false): void {

View file

@ -1,7 +1,7 @@
import { getKeybindings } from "../keybindings.ts";
import { decodeKittyPrintable } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
@ -19,6 +19,7 @@ interface InputState {
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
private renderedStartColumn = 0;
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
@ -210,6 +211,24 @@ export class Input implements Component, Focusable {
}
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type !== "press" || event.button !== "left" || event.y !== 0) return undefined;
const visibleColumn = Math.max(0, event.x - 2);
const targetColumn = this.renderedStartColumn + visibleColumn;
let currentColumn = 0;
this.cursor = this.value.length;
for (const grapheme of segmenter.segment(this.value)) {
const nextColumn = currentColumn + visibleWidth(grapheme.segment);
if (targetColumn < nextColumn) {
this.cursor = grapheme.index;
break;
}
currentColumn = nextColumn;
}
this.lastAction = null;
return { handled: true, focus: true };
}
private insertCharacter(char: string): void {
// Undo coalescing: consecutive word chars coalesce into one undo unit
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
@ -386,6 +405,7 @@ export class Input implements Component, Focusable {
let visibleText = "";
let cursorDisplay = this.cursor;
this.renderedStartColumn = 0;
const totalWidth = visibleWidth(this.value);
if (totalWidth < availableWidth) {
@ -412,6 +432,7 @@ export class Input implements Component, Focusable {
startCol = Math.max(0, cursorCol - halfWidth);
}
this.renderedStartColumn = startCol;
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
cursorDisplay = beforeCursor.length;

View file

@ -0,0 +1,33 @@
import {
type Component,
dispatchMouseEvent,
type TuiMouseDispatchResult,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
export type MouseRegionHandler = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
/** Adds mouse handling to an existing component without changing its rendering. */
export class MouseRegion implements Component {
private readonly child: Component;
private readonly onMouse: MouseRegionHandler;
constructor(child: Component, onMouse: MouseRegionHandler) {
this.child = child;
this.onMouse = onMouse;
}
render(width: number): string[] {
return this.child.render(width);
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined {
const childResult = dispatchMouseEvent(this.child, event);
return childResult ?? this.onMouse(event);
}
invalidate(): void {
this.child.invalidate();
}
}

View file

@ -1,5 +1,5 @@
import { getKeybindings } from "../keybindings.ts";
import type { Component } from "../tui.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth } from "../utils.ts";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
@ -41,6 +41,7 @@ export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private mousePressedIndex: number | undefined;
private maxVisible: number = 5;
private theme: SelectListTheme;
private layout: SelectListLayoutOptions;
@ -83,11 +84,7 @@ export class SelectList implements Component {
const primaryColumnWidth = this.getPrimaryColumnWidth();
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
const { startIndex, endIndex } = this.getVisibleRange();
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
@ -109,6 +106,46 @@ export class SelectList implements Component {
return lines;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.filteredItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
if (this.selectedIndex !== previousIndex) this.notifySelectionChange();
return { handled: true, render: this.selectedIndex !== previousIndex };
}
if (event.type !== "move" && event.button !== "left") return undefined;
const { startIndex, endIndex } = this.getVisibleRange();
const itemIndex = startIndex + event.y;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "move" || event.type === "press") {
if (event.type === "press") this.mousePressedIndex = itemIndex;
const changed = this.selectedIndex !== itemIndex;
if (changed) {
this.selectedIndex = itemIndex;
this.notifySelectionChange();
}
return {
handled: true,
focus: event.type === "press",
...(event.type === "move" ? { render: changed } : {}),
};
}
if (event.type === "click") {
const clickedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
const changed = this.selectedIndex !== clickedIndex;
this.selectedIndex = clickedIndex;
if (changed) this.notifySelectionChange();
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem) this.onSelect?.(selectedItem);
return { handled: true };
}
return undefined;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
@ -136,6 +173,17 @@ export class SelectList implements Component {
}
}
private getVisibleRange(): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
return {
startIndex,
endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length),
};
}
private renderItem(
item: SelectItem,
isSelected: boolean,

View file

@ -1,6 +1,6 @@
import { fuzzyFilter } from "../fuzzy.ts";
import { getKeybindings } from "../keybindings.ts";
import type { Component } from "../tui.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { Input } from "./input.ts";
@ -36,6 +36,7 @@ export class SettingsList implements Component {
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private mousePressedIndex: number | undefined;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
@ -103,7 +104,7 @@ export class SettingsList implements Component {
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const displayItems = this.getDisplayItems();
if (displayItems.length === 0) {
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
this.addHintLine(lines, width);
@ -111,11 +112,7 @@ export class SettingsList implements Component {
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
@ -165,6 +162,53 @@ export class SettingsList implements Component {
return lines;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.submenuComponent) {
const result = this.submenuComponent.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (this.searchEnabled && this.searchInput) {
if (event.y === 0) {
const result = this.searchInput.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (event.y === 1) return undefined;
}
const displayItems = this.getDisplayItems();
if (displayItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta));
return { handled: true, render: this.selectedIndex !== previousIndex };
}
if (event.type !== "move" && event.button !== "left") return undefined;
const rowOffset = this.searchEnabled ? 2 : 0;
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
const itemIndex = startIndex + event.y - rowOffset;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "move" || event.type === "press") {
if (event.type === "press") this.mousePressedIndex = itemIndex;
const changed = this.selectedIndex !== itemIndex;
this.selectedIndex = itemIndex;
return {
handled: true,
focus: event.type === "press",
...(event.type === "move" ? { render: changed } : {}),
};
}
if (event.type === "click") {
this.selectedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
this.activateItem();
return { handled: true };
}
return undefined;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
@ -175,7 +219,7 @@ export class SettingsList implements Component {
// Main list input handling
const kb = getKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const displayItems = this.getDisplayItems();
if (kb.matches(data, "tui.select.up")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
@ -195,8 +239,20 @@ export class SettingsList implements Component {
}
}
private getDisplayItems(): SettingItem[] {
return this.searchEnabled ? this.filteredItems : this.items;
}
private getVisibleRange(displayItems: readonly SettingItem[]): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
return { startIndex, endIndex: Math.min(startIndex + this.maxVisible, displayItems.length) };
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
const item = this.getDisplayItems()[this.selectedIndex];
if (!item) return;
if (item.submenu) {

View file

@ -18,6 +18,7 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts
export { Input } from "./components/input.ts";
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
export { MouseRegion, type MouseRegionHandler } from "./components/mouse-region.ts";
export {
ScrollView,
type ScrollViewOptions,
@ -131,6 +132,10 @@ export {
type TuiInputListener,
type TuiInputListenerResult,
type TuiMode,
type TuiMouseButton,
type TuiMouseEvent,
type TuiMouseEventResult,
type TuiMouseEventType,
type TuiStopOptions,
type ViewportTUI,
} from "./tui.ts";

View file

@ -385,6 +385,19 @@ function containsPoint(rect: LayoutRect, x: number, y: number): boolean {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
/** Return the visual hit path from the deepest component to the layout root. */
export function getLayoutBoxesAt(frame: LayoutFrame, x: number, y: number): LayoutBox[] {
const result: Array<{ box: LayoutBox; depth: number }> = [];
const visit = (box: LayoutBox, depth: number): void => {
if (!containsPoint(box.clip, x, y)) return;
result.push({ box, depth });
for (const child of box.children) visit(child, depth + 1);
};
visit(frame.root, 0);
result.sort((a, b) => b.box.layer - a.box.layer || b.depth - a.depth);
return result.map(({ box }) => box);
}
export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined {
const visit = (box: LayoutBox): LayoutBox | undefined => {
if (box.scrollView === scrollView) return box;

View file

@ -9,6 +9,7 @@ import { ScrollView } from "./components/scroll-view.ts";
import { getKeybindings } from "./keybindings.ts";
import { isKeyRelease } from "./keys.ts";
import {
getLayoutBoxesAt,
getScrollbarGeometry,
getScrollViewBox,
getScrollViewsAt,
@ -16,6 +17,7 @@ import {
renderLayoutFrame,
type ScrollbarGeometry,
} from "./layout.ts";
import { getLayoutNode } from "./layout-node.ts";
import type { Terminal } from "./terminal.ts";
import {
deleteAllKittyImages,
@ -30,10 +32,17 @@ import {
} from "./terminal-image.ts";
import {
type Component,
Container,
CURSOR_MARKER,
compositeTuiLine,
dispatchMouseEvent,
type OverlayHandle,
retargetMouseEvent,
TuiBase,
type TuiMouseButton,
type TuiMouseDispatchResult,
type TuiMouseDispatchTarget,
type TuiMouseEvent,
type TuiStopOptions,
VIEWPORT_TUI,
type ViewportTUI,
@ -109,6 +118,7 @@ interface WheelEvent {
direction: -1 | 1;
x: number;
y: number;
button: number;
}
interface ScrollbarDrag {
@ -191,6 +201,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private activeSearch?: ActiveSearch;
private pressedUrl?: string;
private selectionDragged = false;
private mouseCapture?: TuiMouseDispatchTarget;
private mousePressTarget?: TuiMouseDispatchTarget;
private mousePressPoint?: { x: number; y: number };
private mousePressMoved = false;
private lastComponentClick?: {
timestamp: number;
count: number;
component: Component;
x: number;
y: number;
};
private readonly wheelScrollLines: number;
private readonly mouseEnabled: boolean;
private readonly searchMatchStyle: (text: string) => string;
@ -208,6 +229,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
super(terminal, showHardwareCursor, logDirectory);
this.implicitDocument = {
render: (width) => super.render(width),
handleMouse: (event) => super.handleMouse(event),
invalidate: () => {
for (const child of this.children) child.invalidate();
},
@ -274,6 +296,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.lastClick = undefined;
this.pressedUrl = undefined;
this.selectionDragged = false;
this.clearComponentMouseGesture();
this.lastComponentClick = undefined;
this.resetRenderState();
const term = process.env.TERM?.toLowerCase() ?? "";
// Multiplexers can lag when every pointer movement is forwarded. Button-motion
@ -297,6 +321,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.selectionPressActive = false;
this.stopScrollbarHover();
this.stopScrollbarDrag();
this.clearComponentMouseGesture();
this.flashes.dispose();
if (!this.altScreenActive) return;
this.terminal.write(
@ -536,6 +561,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
}
private clearComponentMouseGesture(): void {
this.mouseCapture = undefined;
this.mousePressTarget = undefined;
this.mousePressPoint = undefined;
this.mousePressMoved = false;
}
private handleViewportInput(data: string): { consume?: boolean } | undefined {
if (data === FOCUS_OUT) {
const hadActiveSelection = this.selectionPressActive;
@ -546,6 +578,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.stopScrollbarDrag();
this.pressedUrl = undefined;
this.selectionDragged = false;
this.clearComponentMouseGesture();
this.lastComponentClick = undefined;
if (hadActiveSelection) {
this.selectionAnchor = undefined;
this.selectionFocus = undefined;
@ -560,16 +594,22 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
const wheelEvent = this.parseWheelEvent(data);
if (wheelEvent) {
const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
wheelDelta: wheelEvent.direction * this.wheelScrollLines,
});
const overlay = this.dispatchMouseToOverlay(event);
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
if (result) {
if (this.applyMouseDispatchResult(event, result)) this.requestRender();
return { consume: true };
}
if (this.shouldDeferViewportInputToOverlay()) return undefined;
this.routeWheel(wheelEvent);
return { consume: true };
}
const mouseEvent = this.parseSgrMouseEvent(data);
if (mouseEvent) {
if (this.handleRightClickPaste(mouseEvent)) return { consume: true };
const handled = this.handleScrollbarMouseEvent(mouseEvent);
if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
if (!handled) this.handleSelectionMouseEvent(mouseEvent);
this.handleMouseEvent(mouseEvent);
return { consume: true };
}
if (this.isMouseSequence(data)) return { consume: true };
@ -642,6 +682,171 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return undefined;
}
private decodeMouseButton(button: number): TuiMouseButton {
switch (button & 3) {
case 0:
return "left";
case 1:
return "middle";
case 2:
return "right";
default:
return "none";
}
}
private createMouseEvent(
type: TuiMouseEvent["type"],
button: number,
x: number,
y: number,
extra: Partial<Pick<TuiMouseEvent, "wheelDelta" | "clickCount">> = {},
): TuiMouseEvent {
return {
type,
button: type === "wheel" ? "none" : this.decodeMouseButton(button),
x,
y,
screenX: x,
screenY: y,
width: Math.max(1, this.terminal.columns),
height: Math.max(1, this.terminal.rows),
shift: (button & 4) !== 0,
alt: (button & 8) !== 0,
ctrl: (button & 16) !== 0,
...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
};
}
private dispatchMouseToLayout(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
if (!this.currentLayout) return undefined;
const visited = new Set<Component>();
const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
for (const box of boxes) {
if (visited.has(box.component)) continue;
if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse) continue;
visited.add(box.component);
const result = dispatchMouseEvent(box.component, {
...event,
x: event.screenX - box.rect.x,
y: event.screenY - box.rect.y,
width: box.rect.width,
height: box.rect.height,
});
if (result) return result;
}
return undefined;
}
private applyMouseDispatchResult(event: TuiMouseEvent, result: TuiMouseDispatchResult): boolean {
const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
if (result.focus) this.setFocus(focusTarget);
if (result.capture) this.mouseCapture = result.target;
return (
result.render ??
(focusChanged ||
event.type === "press" ||
event.type === "click" ||
event.type === "drag" ||
event.type === "wheel")
);
}
private dispatchMouseToTarget(
event: TuiMouseEvent,
target: TuiMouseDispatchTarget,
): TuiMouseDispatchResult | undefined {
return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
}
private getComponentClickCount(target: TuiMouseDispatchTarget, x: number, y: number): number {
const now = Date.now();
const previous = this.lastComponentClick;
const count =
previous &&
now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
previous.component === target.component &&
previous.x === x &&
previous.y === y
? (previous.count % 3) + 1
: 1;
this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
return count;
}
private clearTextSelection(): void {
this.stopSelectionAutoScroll();
this.selectionPressActive = false;
this.selectionAnchor = undefined;
this.selectionFocus = undefined;
this.selectionGranularity = "character";
this.selectionInitialRange = undefined;
this.pressedUrl = undefined;
this.selectionDragged = false;
}
private handleMouseEvent(raw: SgrMouseEvent): void {
const isMotion = (raw.button & 32) !== 0;
const type: TuiMouseEvent["type"] = raw.release
? "release"
: isMotion
? this.decodeMouseButton(raw.button) === "none"
? "move"
: "drag"
: "press";
const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
if (this.mouseCapture || this.mousePressTarget) {
const target = this.mouseCapture ?? this.mousePressTarget!;
if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
this.mousePressMoved = true;
this.lastComponentClick = undefined;
}
let render = false;
const targetResult = this.dispatchMouseToTarget(event, target);
if (targetResult) render = this.applyMouseDispatchResult(event, targetResult);
if (raw.release) {
if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
clickCount: this.getComponentClickCount(target, raw.x, raw.y),
});
const clickResult = this.dispatchMouseToTarget(clickEvent, target);
if (clickResult) render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
}
this.clearComponentMouseGesture();
}
if (render) this.requestRender();
return;
}
const overlay = this.dispatchMouseToOverlay(event);
if (!overlay.hit) {
const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
if (!this.scrollbarDrag) this.updateScrollbarHover(raw.x, raw.y);
if (scrollbarHandled) return;
} else {
this.stopScrollbarHover();
}
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
if (result) {
const render = this.applyMouseDispatchResult(event, result);
if (type === "press") {
this.clearTextSelection();
this.mousePressTarget = result.target;
this.mousePressPoint = { x: raw.x, y: raw.y };
this.mousePressMoved = false;
}
if (render) this.requestRender();
return;
}
if (this.handleRightClickPaste(raw)) return;
this.handleSelectionMouseEvent(raw);
}
private parseWheelEvent(data: string): WheelEvent | undefined {
const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
if (sgr) {
@ -653,6 +858,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
direction: direction === 0 ? -1 : 1,
x: Number.parseInt(sgr[2], 10) - 1,
y: Number.parseInt(sgr[3], 10) - 1,
button,
};
}
if (data.length === 6 && data.startsWith("\x1b[M")) {
@ -664,6 +870,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
direction: direction === 0 ? -1 : 1,
x: data.charCodeAt(4) - 33,
y: data.charCodeAt(5) - 33,
button,
};
}
return undefined;
@ -959,13 +1166,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.stopSelectionAutoScroll();
if (!this.selectionAnchor) return;
this.updateSelectionFocus(point);
const clickedUrl =
const isClick =
!this.selectionDragged &&
this.selectionAnchor.scrollView === point.scrollView &&
this.selectionAnchor.row === point.row &&
this.selectionAnchor.col === point.col
? this.pressedUrl
: undefined;
this.selectionAnchor.col === point.col;
const clickedUrl = isClick ? this.pressedUrl : undefined;
this.pressedUrl = undefined;
if (clickedUrl && this.openUrl) {
this.selectionAnchor = undefined;
@ -978,6 +1184,19 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.requestRender();
return;
}
if (isClick) {
const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
clickCount: this.lastClick?.count ?? 1,
});
const overlay = this.dispatchMouseToOverlay(clickEvent);
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
if (result) {
const render = this.applyMouseDispatchResult(clickEvent, result);
this.clearTextSelection();
if (render) this.requestRender();
return;
}
}
void this.copySelectionToClipboard();
this.requestRender();
return;

View file

@ -20,6 +20,96 @@ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth
/**
* Component interface - all components must implement this
*/
export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel";
export type TuiMouseButton = "left" | "middle" | "right" | "none";
/** Normalized cell-based mouse event. Coordinates are zero-based. */
export interface TuiMouseEvent {
type: TuiMouseEventType;
button: TuiMouseButton;
/** Coordinates local to the receiving component. */
x: number;
y: number;
/** Absolute terminal coordinates. */
screenX: number;
screenY: number;
/** Current component bounds. */
width: number;
height: number;
shift: boolean;
alt: boolean;
ctrl: boolean;
/** Logical lines. Negative values scroll up. */
wheelDelta?: number;
/** Consecutive click count when type is click. */
clickCount?: number;
}
export interface TuiMouseEventResult {
/** Stop propagation and suppress renderer-level fallback behavior. */
handled?: boolean;
/** Route subsequent drag/release events to this component. Implies handled. */
capture?: boolean;
/** Give keyboard focus to this component. Implies handled. */
focus?: boolean;
/**
* Explicitly request or suppress a render. Move and release default to false;
* press, click, drag, and wheel default to true.
*/
render?: boolean;
}
/** Internal target metadata used by containers and alternate-screen dispatch. */
export interface TuiMouseDispatchTarget {
component: Component;
originX: number;
originY: number;
width: number;
height: number;
}
/** Result of dispatching to a concrete component. */
export interface TuiMouseDispatchResult extends TuiMouseEventResult {
handled: true;
target: TuiMouseDispatchTarget;
/** Keyboard focus target, which may be a delegating parent container. */
focusTarget?: Component;
}
/**
* Dispatch an event to a component and retain the exact target and coordinate
* transform. Containers use this when forwarding events to nested children.
*/
export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
const result = component.handleMouse?.(event);
if (!result) return undefined;
if ("target" in result) return result as TuiMouseDispatchResult;
if (!result.handled && !result.capture && !result.focus) return undefined;
return {
...result,
handled: true,
...(result.focus ? { focusTarget: component } : {}),
target: {
component,
originX: event.screenX - event.x,
originY: event.screenY - event.y,
width: event.width,
height: event.height,
},
};
}
/** Recreate local coordinates for a previously dispatched mouse target. */
export function retargetMouseEvent(event: TuiMouseEvent, target: TuiMouseDispatchTarget): TuiMouseEvent {
return {
...event,
x: event.screenX - target.originX,
y: event.screenY - target.originY,
width: target.width,
height: target.height,
};
}
export interface Component {
/**
* Render the component to lines for the given viewport width
@ -28,11 +118,12 @@ export interface Component {
*/
render(width: number): string[];
/**
* Optional handler for keyboard input when component has focus
*/
/** Optional handler for keyboard input when component has focus. */
handleInput?(data: string): void;
/** Optional normalized mouse handler. */
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
/**
* If true, component receives key release events (Kitty protocol).
* Default is false - release events are filtered out.
@ -193,6 +284,14 @@ type OverlayStackEntry = {
focusOrder: number;
};
type RenderedOverlayLayout = {
entry: OverlayStackEntry;
row: number;
col: number;
width: number;
height: number;
};
type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };
type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };
type BlockedOverlayFocusRestoreState = {
@ -210,6 +309,7 @@ type OverlayFocusRestorePolicy = "clear" | "preserve";
*/
export class Container implements Component {
children: Component[] = [];
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
addChild(component: Component): void {
this.children.push(component);
@ -232,14 +332,39 @@ export class Container implements Component {
}
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
if (event.y < 0 || event.y >= event.height) return undefined;
const mouseChildren =
this.mouseLayout?.width === event.width
? this.mouseLayout.children
: this.children.map((component) => ({ component, height: component.render(event.width).length }));
let childY = 0;
for (const { component: child, height: childHeight } of mouseChildren) {
if (event.y >= childY && event.y < childY + childHeight) {
const result = dispatchMouseEvent(child, {
...event,
y: event.y - childY,
height: childHeight,
});
if (result?.focus && (this as Component).handleInput) return { ...result, focusTarget: this };
return result;
}
childY += childHeight;
}
return undefined;
}
render(width: number): string[] {
const lines: string[] = [];
const mouseChildren: Array<{ component: Component; height: number }> = [];
for (const child of this.children) {
const childLines = child.render(width);
mouseChildren.push({ component: child, height: childLines.length });
for (const line of childLines) {
lines.push(line);
}
}
this.mouseLayout = { width, children: mouseChildren };
return lines;
}
}
@ -354,6 +479,7 @@ export abstract class TuiBase extends Container implements TUI {
// Overlay stack for modal components rendered on top of base content
private focusOrderCounter = 0;
private overlayStack: OverlayStackEntry[] = [];
private renderedOverlayLayouts: RenderedOverlayLayout[] = [];
get hasOverlayEntries(): boolean {
return this.overlayStack.length > 0;
@ -669,6 +795,46 @@ export abstract class TuiBase extends Container implements TUI {
);
}
/** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
protected resolveMouseFocusTarget(component: Component): Component {
for (let index = this.overlayStack.length - 1; index >= 0; index--) {
const overlay = this.overlayStack[index]!;
if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
return overlay.component;
}
}
return component;
}
/** Dispatch to the visually topmost overlay under the pointer. */
protected dispatchMouseToOverlay(event: TuiMouseEvent): { hit: boolean; result?: TuiMouseDispatchResult } {
for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
const layout = this.renderedOverlayLayouts[index]!;
if (
event.screenX < layout.col ||
event.screenX >= layout.col + layout.width ||
event.screenY < layout.row ||
event.screenY >= layout.row + layout.height
) {
continue;
}
const result = dispatchMouseEvent(layout.entry.component, {
...event,
x: event.screenX - layout.col,
y: event.screenY - layout.row,
width: layout.width,
height: layout.height,
});
return result
? {
hit: true,
result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
}
: { hit: true };
}
return { hit: false };
}
/** Check if an overlay entry is currently visible */
private isOverlayVisible(entry: OverlayStackEntry): boolean {
if (entry.hidden) return false;
@ -1097,11 +1263,14 @@ export abstract class TuiBase extends Container implements TUI {
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
if (this.overlayStack.length === 0) return lines;
if (this.overlayStack.length === 0) {
this.renderedOverlayLayouts = [];
return lines;
}
const result = [...lines];
// Pre-render all visible overlays and calculate positions
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = [];
let minLinesNeeded = result.length;
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
@ -1124,9 +1293,16 @@ export abstract class TuiBase extends Container implements TUI {
// Get final row/col with actual overlay height
const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
rendered.push({ overlayLines, row, col, w: width });
rendered.push({ entry, overlayLines, row, col, w: width });
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
}
this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
entry,
row,
col,
width: w,
height: overlayLines.length,
}));
// Pad to at least terminal height so overlays have screen-relative positions.
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing

View file

@ -0,0 +1,153 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Editor, type EditorTheme } from "../src/components/editor.ts";
import { Input } from "../src/components/input.ts";
import { SelectList, type SelectListTheme } from "../src/components/select-list.ts";
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
import { Container, type TuiMouseEvent, type TuiMouseEventType } from "../src/tui.ts";
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
function mouse(type: TuiMouseEventType, x: number, y: number, width = 80, height = 10): TuiMouseEvent {
return {
type,
button: "left",
x,
y,
screenX: x,
screenY: y,
width,
height,
shift: false,
alt: false,
ctrl: false,
...(type === "click" ? { clickCount: 1 } : {}),
};
}
const selectTheme: SelectListTheme = {
selectedPrefix: (text) => text,
selectedText: (text) => text,
description: (text) => text,
scrollInfo: (text) => text,
noMatch: (text) => text,
};
const settingsTheme: SettingsListTheme = {
label: (text) => text,
value: (text) => text,
description: (text) => text,
cursor: "> ",
hint: (text) => text,
};
const editorTheme: EditorTheme = {
borderColor: (text) => text,
selectList: selectTheme,
};
class InputOverlay extends Container {
readonly input = new Input();
constructor() {
super();
this.addChild(this.input);
}
handleInput(data: string): void {
this.input.handleInput(data);
}
}
describe("mouse-aware components", () => {
it("positions a single-line input cursor on press", () => {
const input = new Input();
input.setValue("hello");
input.render(20);
assert.strictEqual(input.handleMouse(mouse("press", 4, 0, 20, 1))?.handled, true);
input.handleInput("X");
assert.strictEqual(input.getValue(), "heXllo");
});
it("selects and activates list rows", () => {
const list = new SelectList(
[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
{ value: "d", label: "D" },
{ value: "e", label: "E" },
],
3,
selectTheme,
);
let selected: string | undefined;
list.onSelect = (item) => {
selected = item.value;
};
assert.strictEqual(list.handleMouse(mouse("press", 1, 2, 40, 3))?.handled, true);
assert.strictEqual(list.getSelectedItem()?.value, "c");
assert.strictEqual(list.handleMouse(mouse("click", 1, 2, 40, 3))?.handled, true);
assert.strictEqual(selected, "c");
});
it("activates settings rows", () => {
const changes: Array<{ id: string; value: string }> = [];
const list = new SettingsList(
[
{ id: "mode", label: "Mode", currentValue: "one", values: ["one", "two"] },
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
{ id: "third", label: "Third", currentValue: "low", values: ["low", "high"] },
{ id: "fourth", label: "Fourth", currentValue: "x", values: ["x", "y"] },
],
3,
settingsTheme,
(id, value) => changes.push({ id, value }),
() => {},
);
list.handleMouse(mouse("press", 1, 2, 40, 5));
list.handleMouse(mouse("click", 1, 2, 40, 5));
assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]);
});
it("keeps a delegating overlay focused when its nested input is clicked", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const overlay = new InputOverlay();
overlay.input.setValue("hi");
tui.start();
tui.showOverlay(overlay, { anchor: "top-left", width: 20 });
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;5;1M");
terminal.sendInput("\x1b[<0;5;1m");
terminal.sendInput("!");
await terminal.waitForRender();
assert.strictEqual(overlay.input.getValue(), "hi!");
assert.strictEqual(tui.getFocusedComponent(), overlay);
tui.stop();
});
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const editor = new Editor(tui, editorTheme);
editor.setText("hello");
tui.addChild(editor);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;3;2M");
terminal.sendInput("\x1b[<0;3;2m");
terminal.sendInput("X");
await terminal.waitForRender();
assert.strictEqual(editor.getText(), "heXllo");
assert.strictEqual(tui.getFocusedComponent(), editor);
tui.stop();
});
});

View file

@ -3,7 +3,9 @@ import { describe, it } from "node:test";
import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts";
import { HStack } from "../src/components/h-stack.ts";
import { Image } from "../src/components/image.ts";
import { MouseRegion } from "../src/components/mouse-region.ts";
import { ScrollView } from "../src/components/scroll-view.ts";
import { SelectList } from "../src/components/select-list.ts";
import { Text } from "../src/components/text.ts";
import { VStack } from "../src/components/v-stack.ts";
import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts";
@ -14,6 +16,7 @@ import {
resetCapabilitiesCache,
setCapabilities,
} from "../src/terminal-image.ts";
import type { TuiMouseEvent } from "../src/tui.ts";
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
@ -178,6 +181,43 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("does not vertically redispatch misses through horizontal layout containers", async () => {
const terminal = new VirtualTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let selections = 0;
const list = new SelectList(
[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
2,
{
selectedPrefix: (text) => text,
selectedText: (text) => text,
description: (text) => text,
scrollInfo: (text) => text,
noMatch: (text) => text,
},
);
list.onSelect = () => {
selections += 1;
};
tui.setLayoutRoot(
new HStack([
{ component: list, basis: 10 },
{ component: new Text("plain", 0, 0), basis: 10 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;15;1M");
terminal.sendInput("\x1b[<0;15;1m");
await terminal.waitForRender();
assert.strictEqual(selections, 0);
tui.stop();
});
it("uses button-motion tracking inside terminal multiplexers", () => {
const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const;
const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
@ -1295,6 +1335,137 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("dispatches clicks to nested mouse regions without breaking drag selection", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let clicks = 0;
tui.addChild(
new MouseRegion(new Text("clickable\nselectable", 0, 0), (event) => {
if (event.type !== "click") return undefined;
clicks += 1;
return { handled: true };
}),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;2;1M");
terminal.sendInput("\x1b[<0;2;1m");
await terminal.waitForRender();
assert.strictEqual(clicks, 1);
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.strictEqual(clicks, 1);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b]52;c;")));
tui.stop();
});
it("focuses and captures drag gestures for mouse-aware components", async () => {
const terminal = new VirtualTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
const events: string[] = [];
const component = {
render: () => ["control"],
invalidate: () => {},
handleMouse: (event: TuiMouseEvent) => {
events.push(event.type);
return event.type === "press" ? { handled: true, capture: true, focus: true } : { handled: true };
},
};
tui.addChild(component);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;5;2M");
terminal.sendInput("\x1b[<0;5;2m");
await terminal.waitForRender();
assert.deepStrictEqual(events, ["press", "drag", "release"]);
assert.strictEqual(tui.getFocusedComponent(), component);
tui.stop();
});
it("reports consecutive click counts to component-owned controls", async () => {
const terminal = new VirtualTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
const clickCounts: number[] = [];
tui.addChild({
render: () => ["control"],
invalidate: () => {},
handleMouse: (event) => {
if (event.type === "press") return { handled: true };
if (event.type === "click") {
clickCounts.push(event.clickCount ?? 0);
return { handled: true };
}
return undefined;
},
});
tui.start();
await terminal.waitForRender();
for (let count = 0; count < 3; count++) {
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<0;1;1m");
}
await terminal.waitForRender();
assert.deepStrictEqual(clickCounts, [1, 2, 3]);
tui.stop();
});
it("does not rerender for handled no-op pointer motion", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let renderCount = 0;
tui.addChild({
render: () => {
renderCount += 1;
return ["hover target"];
},
invalidate: () => {},
handleMouse: (event) => (event.type === "move" ? { handled: true } : undefined),
});
tui.start();
await terminal.waitForRender();
const renderedBeforeMotion = renderCount;
const writesBeforeMotion = terminal.events.filter((event) => event.type === "write").length;
terminal.sendInput("\x1b[<35;1;1M");
await terminal.waitForRender();
assert.strictEqual(renderCount, renderedBeforeMotion);
assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, writesBeforeMotion);
tui.stop();
});
it("lets mouse-aware components consume wheel events before viewport scrolling", async () => {
const terminal = new VirtualTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
let wheelEvents = 0;
tui.addChild(
new MouseRegion(
new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
(event) => {
if (event.type !== "wheel") return undefined;
wheelEvents += 1;
return { handled: true };
},
),
);
tui.start();
await terminal.waitForRender();
const viewportTop = tui.viewportTop;
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
assert.strictEqual(wheelEvents, 1);
assert.strictEqual(tui.viewportTop, viewportTop);
tui.stop();
});
it("restores keyboard state before leaving alt mode and prints the full document", async () => {
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);