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.isTTYso 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