From 19872c3d606e53f262e45fcc759892279915da91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 18:32:32 +0200 Subject: [PATCH 1/2] Enable SGR mouse tracking and parse mouse escape sequences Implement mouse tracking foundation for interactive TUI (#167): - Add src/render/mouse.ts with SGR parser for mouse events - Parser recognizes button codes, coordinates, press/release - Enable mouse tracking (\x1b[?1000h\x1b[?1006h) on TUI start - Disable tracking (\x1b[?1000l\x1b[?1006l) on exit - Wire parser into stdin loop in tui.ts; silently ignore unrecognized sequences - 15 unit tests with 100% coverage for parseMouseEvent - All validation checks pass (test, lint, format, knip, build) --- src/render/mouse.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++ src/render/mouse.ts | 33 +++++++++++++++++ src/tui.ts | 12 ++++++ 3 files changed, 124 insertions(+) create mode 100644 src/render/mouse.test.ts create mode 100644 src/render/mouse.ts diff --git a/src/render/mouse.test.ts b/src/render/mouse.test.ts new file mode 100644 index 0000000..b960452 --- /dev/null +++ b/src/render/mouse.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "bun:test"; +import { parseMouseEvent, type MouseEvent } from "./mouse.ts"; + +describe("parseMouseEvent", () => { + it("parses a valid left click (button 0) press", () => { + const event = parseMouseEvent("\x1b[<0;12;5M"); + expect(event).toEqual({ button: 0, x: 12, y: 5, isRelease: false }); + }); + + it("parses a valid left click (button 0) release", () => { + const event = parseMouseEvent("\x1b[<0;12;5m"); + expect(event).toEqual({ button: 0, x: 12, y: 5, isRelease: true }); + }); + + it("parses a valid middle click (button 1)", () => { + const event = parseMouseEvent("\x1b[<1;100;50M"); + expect(event).toEqual({ button: 1, x: 100, y: 50, isRelease: false }); + }); + + it("parses a valid right click (button 2)", () => { + const event = parseMouseEvent("\x1b[<2;5;1M"); + expect(event).toEqual({ button: 2, x: 5, y: 1, isRelease: false }); + }); + + it("parses wheel up (button 64)", () => { + const event = parseMouseEvent("\x1b[<64;10;10M"); + expect(event).toEqual({ button: 64, x: 10, y: 10, isRelease: false }); + }); + + it("parses wheel down (button 65)", () => { + const event = parseMouseEvent("\x1b[<65;10;10M"); + expect(event).toEqual({ button: 65, x: 10, y: 10, isRelease: false }); + }); + + it("returns null for malformed sequence (missing bracket)", () => { + const event = parseMouseEvent("\x1b[0;12;5M"); + expect(event).toBeNull(); + }); + + it("returns null for malformed sequence (non-numeric button)", () => { + const event = parseMouseEvent("\x1b[ { + const event = parseMouseEvent("\x1b[<0,12;5M"); + expect(event).toBeNull(); + }); + + it("returns null for empty string", () => { + const event = parseMouseEvent(""); + expect(event).toBeNull(); + }); + + it("returns null for partial/truncated sequence", () => { + const event = parseMouseEvent("\x1b[<0;12"); + expect(event).toBeNull(); + }); + + it("returns null for unrelated escape sequence", () => { + const event = parseMouseEvent("\x1b[A"); // arrow up + expect(event).toBeNull(); + }); + + it("returns null for non-escape sequence", () => { + const event = parseMouseEvent("hello"); + expect(event).toBeNull(); + }); + + it("handles edge-case coordinates (0, 0)", () => { + const event = parseMouseEvent("\x1b[<0;0;0M"); + expect(event).toEqual({ button: 0, x: 0, y: 0, isRelease: false }); + }); + + it("handles large coordinates (999, 999)", () => { + const event = parseMouseEvent("\x1b[<0;999;999M"); + expect(event).toEqual({ button: 0, x: 999, y: 999, isRelease: false }); + }); +}); diff --git a/src/render/mouse.ts b/src/render/mouse.ts new file mode 100644 index 0000000..7365940 --- /dev/null +++ b/src/render/mouse.ts @@ -0,0 +1,33 @@ +// Mouse event parsing for SGR-mode terminal mouse reporting. +// SGR format: CSI [< button ; x ; y M/m where M=press, m=release + +export interface MouseEvent { + button: number; + x: number; + y: number; + isRelease: boolean; +} + +/** + * Parses an SGR-format mouse event sequence. + * Returns a structured event or null if the sequence doesn't match or is malformed. + * + * SGR format: \x1b[ { + // Disable SGR mouse reporting and clear terminal + process.stdout.write("\x1b[?1000l\x1b[?1006l"); process.stdout.write(ANSI_CLEAR); process.stdin.setRawMode(false); process.off("SIGWINCH", onResize); @@ -283,6 +288,13 @@ export async function runInteractive( for await (const chunk of process.stdin) { const key = chunk.toString(); + // Try parsing as a mouse event; if it's not a mouse event, treat as keyboard input + const mouseEvent = parseMouseEvent(key); + if (mouseEvent !== null) { + // Mouse events are silently ignored for now; this hook is here for future #170 / #168 + continue; + } + // Reset the gg pending state on every key that isn't a sequence of one // or more plain "g" characters. This allows terminals that batch key // repeats (e.g. delivering "gg" in a single chunk) to still participate From f08fb4dbb977b1e4901134a73d1c5930566c5585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 20:56:33 +0200 Subject: [PATCH 2/2] Fix oxlint errors: unused MouseEvent import and control character regex --- src/render/mouse.test.ts | 2 +- src/render/mouse.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/render/mouse.test.ts b/src/render/mouse.test.ts index b960452..c82c875 100644 --- a/src/render/mouse.test.ts +++ b/src/render/mouse.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { parseMouseEvent, type MouseEvent } from "./mouse.ts"; +import { parseMouseEvent } from "./mouse.ts"; describe("parseMouseEvent", () => { it("parses a valid left click (button 0) press", () => { diff --git a/src/render/mouse.ts b/src/render/mouse.ts index 7365940..fc30322 100644 --- a/src/render/mouse.ts +++ b/src/render/mouse.ts @@ -20,8 +20,11 @@ export interface MouseEvent { * 64 = wheel up * 65 = wheel down */ +const SGR_MOUSE_PREFIX = "\x1b[<"; + export function parseMouseEvent(sequence: string): MouseEvent | null { - const match = sequence.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/); + if (!sequence.startsWith(SGR_MOUSE_PREFIX)) return null; + const match = sequence.slice(SGR_MOUSE_PREFIX.length).match(/^(\d+);(\d+);(\d+)([Mm])$/); if (!match) return null; const button = parseInt(match[1], 10);