Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/render/mouse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from "bun:test";
import { parseMouseEvent } 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[<a;12;5M");
expect(event).toBeNull();
});

it("returns null for malformed sequence (missing semicolon)", () => {
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 });
});
});
36 changes: 36 additions & 0 deletions src/render/mouse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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[<button;x;yM (press) or \x1b[<button;x;ym (release)
* Button codes:
* 0 = left click
* 1 = middle click
* 2 = right click
* 64 = wheel up
* 65 = wheel down
*/
const SGR_MOUSE_PREFIX = "\x1b[<";

export function parseMouseEvent(sequence: string): MouseEvent | null {
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);
const x = parseInt(match[2], 10);
const y = parseInt(match[3], 10);
const isRelease = match[4] === "m"; // lowercase m = release, uppercase M = press

return { button, x, y, isRelease };
}
12 changes: 12 additions & 0 deletions src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
flattenTeamSections,
rebuildTeamSections,
} from "./group.ts";
import { parseMouseEvent } from "./render/mouse.ts";
import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts";
Comment thread
shouze marked this conversation as resolved.

// ─── Key binding constants ────────────────────────────────────────────────────
Expand Down Expand Up @@ -127,6 +128,8 @@ export async function runInteractive(

process.stdin.setRawMode(true);
readline.emitKeypressEvents(process.stdin);
// Enable SGR mouse reporting on the terminal
process.stdout.write("\x1b[?1000h\x1b[?1006h");

Comment thread
shouze marked this conversation as resolved.
let cursor = 0;
let scrollOffset = 0;
Expand Down Expand Up @@ -261,6 +264,8 @@ export async function runInteractive(

// ─── Exit handler for cleanup ────────────────────────────────────────────
const exit = () => {
// 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);
Expand All @@ -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
Expand Down
Loading