
Building Modern Terminal Apps with Mouse Support
Text-mode applications were once keyboard-only, but today users expect to click, scroll, and drag—even inside the terminal. Adding pointer support:
- Speeds navigation - skip arrow-key marathons
- Feels familiar - re-uses GUI muscle memory
- Enables richer widgets - scrollable lists, draggable panes, resizable splits
Because a terminal is still a bidirectional byte stream, the app must:
- Tell the emulator to emit mouse reports (special escape codes).
- Parse those bytes from
stdin
. - Map them to UI state (hover, press, wheel, drag).
How Terminals Report Mouse Events
Modern emulators support the XTerm mouse protocols. Request SGR (1006) first and fall back if needed.
Protocol | Enable Seq. | Coordinates | Buttons |
---|---|---|---|
X10 (1989) | ESC[?9h | 1-based 0-255 | press only |
VT200 | ESC[?1000h | 1-based 0-255 | press + release |
UTF-8 | ESC[?1005h | 1-based ∞ | >255 cols |
SGR (1006) | ESC[?1006h | 1-based ∞ | press, release, drag, wheel |
SGR Packet Anatomy
SGR packets’ escape sequences look like this:
ESC [ < btn ; col ; row (M | m)
Where:
- ESC = escape character (0x1b)
- [ = CSI (Control Sequence Introducer)
- < = start of mouse report
- btn - button/wheel + modifiers (Shift+4, Alt+8, Ctrl+16)
- col / row - 1-based position
- M = press/drag, m = release
A scroll wheel sends btn = 64
(up) or 65
(down) with only a press event.
Enabling & Cleaning Up
Set the stream to raw so bytes arrive immediately, then toggle SGR mode on mount and off on exit.
process.stdin.setRawMode?.(true);
process.stdin.resume();
export const enableMouse = () =>
process.stdout.write("\x1b[?1002h\x1b[?1006h"); // drag + SGR
export const disableMouse = () =>
process.stdout.write("\x1b[?1002l\x1b[?1006l");
Guard with
process.stdout.isTTY
so tests and CI logs stay clean.
From Bytes to a MouseEvent
We ‘ll convert each SGR packet into a strongly-typed object the rest of the app can use.
// types.ts
export interface MouseEvent {
x: number; // 0-based col
y: number; // 0-based row
button: "left" | "middle" | "right" | "wheelUp" | "wheelDown";
action: "press" | "release" | "drag";
shift: boolean;
alt: boolean;
ctrl: boolean;
}
Parsing Helper
// parseMouse.ts
const RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
export const parseSGR = (buf: Buffer): MouseEvent | null => {
const m = RE.exec(buf.toString());
if (!m) return null;
const [ , codeS, colS, rowS, suf ] = m;
const code = +codeS;
const wheel = code & 0b11000000;
const btnId = code & 0b11;
const button = wheel === 64 ? "wheelUp" :
wheel === 65 ? "wheelDown" :
btnId === 0 ? "left" :
btnId === 1 ? "middle" :
"right";
return {
x: +colS - 1,
y: +rowS - 1,
button,
action: suf === "M" && !wheel ? "press" :
suf === "m" ? "release" : "drag",
shift: !!(code & 4),
alt: !!(code & 8),
ctrl: !!(code & 16),
};
};
The regex is the only heavy work; everything else is bit-twiddling.
useMouse
- A Thin Ink Hook
Ink’s useInput
mixes keys and mouse bytes. Wrapping our parser in a custom hook keeps components clean.
import {useEffect} from "react";
import {enableMouse, disableMouse} from "./mouseMode.js";
import {parseSGR, MouseEvent} from "./parseMouse.js";
export const useMouse = (onEvent: (e: MouseEvent) => void) => {
useEffect(() => {
enableMouse();
const handler = (buf: Buffer) => parseSGR(buf) && onEvent(parseSGR(buf)!);
process.stdin.on("data", handler);
return () => {
process.stdin.off("data", handler);
disableMouse();
};
}, [onEvent]);
};
Composable Widgets
<MouseArea>
- limit pointer events to a region
A wrapper that forwards events only when the cursor is inside its box.
import React, {useLayoutEffect, useRef, useState} from "react";
import {Box, measureElement} from "ink";
import {useMouse} from "../hooks/useMouse.js";
import {MouseEvent} from "../utils/parseMouse.js";
export const MouseArea: React.FC<{onMouse?: (e: MouseEvent) => void}> = ({onMouse, children}) => {
const ref = useRef<any>(null);
const [rect, setRect] = useState({x:0, y:0, w:0, h:0});
useLayoutEffect(() => {
if (ref.current) {
const m = measureElement(ref.current);
setRect({x:m.x, y:m.y, w:m.width, h:m.height});
}
});
useMouse(e => {
const inside = e.x >= rect.x && e.x < rect.x + rect.w &&
e.y >= rect.y && e.y < rect.y + rect.h;
inside && onMouse?.(e);
});
return <Box ref={ref}>{children}</Box>;
};
<ClickableButton>
- hover & click feedback
A minimal button that changes color on hover and reports clicks.
import React, {useState} from "react";
import {Box, Text} from "ink";
import {MouseArea} from "./MouseArea.js";
import {MouseEvent} from "../utils/parseMouse.js";
export const ClickableButton: React.FC<{label: string; onPress: () => void}> = ({label, onPress}) => {
const [hover, setHover] = useState(false);
const [active, setActive] = useState(false);
const handle = (e: MouseEvent) => {
if (e.action === "press" && e.button === "left") setActive(true);
if (e.action === "release") {
active && onPress();
setActive(false);
}
setHover(e.action !== "release");
};
return (
<MouseArea onMouse={handle}>
<Box borderStyle="round" borderColor={active ? "yellow" : hover ? "cyan" : "gray"} paddingX={1}>
<Text bold={hover}>{label}</Text>
</Box>
</MouseArea>
);
};
Putting It All Together
import React from "react";
import {render, Box, Text} from "ink";
import {ClickableButton} from "./components/ClickableButton.js";
const App = () => (
<Box flexDirection="column" gap={1}>
<Text>Try clicking the button ⬇︎</Text>
<ClickableButton label="Hello world" onPress={() => console.log("Button pressed")}/>
</Box>
);
render(<App />);
Run:
node app.js
You now handle click, hover, wheel, and drag events—ready for lists, panes, or custom widgets.
Best Practices & Pitfalls
- Always clean up - disable mouse mode and raw input on exit or error.
- Skip when not TTY - piping output? Don ‘t emit escapes.
node app.js