/** * Mouse-click support via SGR mouse reporting. * * SGR mode (`CSI ? 1006 h`) is the modern, well-supported encoding — * iTerm2, kitty, Alacritty, WezTerm, modern Windows Terminal all * speak it. Format on press: `ESC [ < button ; col ; row M`; on * release the trailing M becomes m. * * Buttons in SGR mode: * 0 = left, 1 = middle, 2 = right * 64 = wheel up, 65 = wheel down * modifiers (shift/alt/ctrl) get OR'd into the button code (4/8/16) * * `CSI ? 1000 h` enables button-event reporting (presses + releases, * no motion). We don't need motion or any-event mode for click-to- * focus. */ const ESC = '\x1b'; export const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`; export const MOUSE_OFF = `${ESC}[?1000l${ESC}[?1006l`; export interface MouseEvent { button: number; /** 0-based terminal column. SGR reports 1-based; we normalize. */ col: number; /** 0-based terminal row. */ row: number; pressed: boolean; } /** * Match an SGR mouse sequence in a chunk of input. Ink's `useInput` * sees mouse events as `key.escape: true` + an `input` like * `[<0;5;3M`, so we accept both that form (Ink-relayed) and the raw * form including the leading ESC + CSI. Built from a string at * runtime so the `\x1b` control byte doesn't trip biome's "control * character in regex" lint. */ const SGR_PATTERN = new RegExp(`^(?:${ESC}\\[|\\[)<(\\d+);(\\d+);(\\d+)([Mm])`); export function parseMouseEvent(input: string): MouseEvent | null { const match = input.match(SGR_PATTERN); if (!match) return null; return { button: Number.parseInt(match[1], 10), // SGR coordinates are 1-based; our layout math is 0-based. col: Number.parseInt(match[2], 10) - 1, row: Number.parseInt(match[3], 10) - 1, pressed: match[4] === 'M', }; } export interface ClickRegions { /** Width and height of the body (everything above the keybar). */ bodyHeight: number; leftColWidth: number; /** rightColWidth is implied by total columns; not needed for hit-testing. */ summaryHeight: number; categoriesHeight: number; // findingsHeight is the remainder of the left column. detailHeight: number; // commandLogHeight is the remainder of the right column. } export type PaneId = 'summary' | 'categories' | 'findings' | 'detail' | 'log'; export interface PaneHit { pane: PaneId; /** * 0-based row offset relative to the pane's top edge (border * inclusive). Useful for translating a click into a list index in * scrollable panes. */ rowInPane: number; } /** * Hit-test a click against the pane layout. Returns the pane the * click landed in (with the row offset within that pane), or null * if the click is outside the body (e.g. keybar row). */ export function paneAt(col: number, row: number, regions: ClickRegions): PaneHit | null { if (row < 0 || row >= regions.bodyHeight) return null; if (col < 0) return null; if (col < regions.leftColWidth) { if (row < regions.summaryHeight) { return { pane: 'summary', rowInPane: row }; } if (row < regions.summaryHeight + regions.categoriesHeight) { return { pane: 'categories', rowInPane: row - regions.summaryHeight }; } return { pane: 'findings', rowInPane: row - regions.summaryHeight - regions.categoriesHeight, }; } // Right column. if (row < regions.detailHeight) { return { pane: 'detail', rowInPane: row }; } return { pane: 'log', rowInPane: row - regions.detailHeight }; } /** * Each pane's "chrome" — non-content rows above the first list item. * Categories: top border (1) + title (1) + marginTop spacer (1) = 3. * Findings: same. (Bottom border doesn't matter; we only care * about the top offset.) */ export const LIST_PANE_CHROME = 3;