diff --git a/.github/skills/bug-fixing.md b/.github/skills/bug-fixing.md index 14b107e..d19bb48 100644 --- a/.github/skills/bug-fixing.md +++ b/.github/skills/bug-fixing.md @@ -7,28 +7,31 @@ This skill complements `.github/instructions/bug-fixing.instructions.md`. ## Symptom → module diagnostic table -| Symptom | Primary suspect | Secondary suspect | -| ----------------------------------------------------------- | ----------------------------------------------- | --------------------------------- | -| Results missing or duplicated | `src/aggregate.ts` | `src/api.ts` (pagination) | -| Wrong repository grouping | `src/group.ts` | `src/aggregate.ts` | -| `--exclude-repositories` / `--exclude-extracts` not working | `src/aggregate.ts` | `github-code-search.ts` (parsing) | -| Markdown output malformed | `src/output.ts` | — | -| JSON output missing fields or wrong shape | `src/output.ts` | `src/types.ts` (interface) | -| Syntax highlighting wrong colour / wrong language | `src/render/highlight.ts` | — | -| Row navigation skips or wraps incorrectly | `src/render/rows.ts` | `src/tui.ts` (key handler) | -| Select-all / select-none inconsistent | `src/render/selection.ts` | `src/tui.ts` | -| Filter count / stats incorrect | `src/render/filter.ts`, `src/render/summary.ts` | — | -| Path filter (`/regex/`) doesn't match expected | `src/render/filter-match.ts` | `src/tui.ts` (filter state) | -| API returns 0 results or stops paginating | `src/api.ts` | `src/api-utils.ts` | -| Rate limit hit / 429 not retried | `src/api-utils.ts` (`fetchWithRetry`) | — | -| TUI shows blank screen or wrong row | `src/tui.ts` | `src/render/rows.ts` | -| Help overlay doesn't appear / has wrong keys | `src/render.ts` (`renderHelpOverlay`) | `src/tui.ts` | -| Upgrade fails or replaces wrong binary | `src/upgrade.ts` | — | -| Completion script wrong content | `src/completions.ts` | — | -| Completion file written to wrong path | `src/completions.ts` (`getCompletionFilePath`) | env vars (`XDG_*`, `ZDOTDIR`) | -| Completion not refreshed after upgrade | `src/upgrade.ts` (`refreshCompletions`) | — | -| `--version` shows wrong info | `build.ts` (SHA injection) | — | -| CLI option ignored or parsed wrong | `github-code-search.ts` | `src/types.ts` (`OutputType`) | +| Symptom | Primary suspect | Secondary suspect | +| ----------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- | +| Results missing or duplicated | `src/aggregate.ts` | `src/api.ts` (pagination) | +| Wrong repository grouping | `src/group.ts` | `src/aggregate.ts` | +| `--exclude-repositories` / `--exclude-extracts` not working | `src/aggregate.ts` | `github-code-search.ts` (parsing) | +| Markdown output malformed | `src/output.ts` | — | +| JSON output missing fields or wrong shape | `src/output.ts` | `src/types.ts` (interface) | +| Syntax highlighting wrong colour / wrong language | `src/render/highlight.ts` | — | +| Row navigation skips or wraps incorrectly | `src/render/rows.ts` | `src/tui.ts` (key handler) | +| Select-all / select-none inconsistent | `src/render/selection.ts` | `src/tui.ts` | +| Filter count / stats incorrect | `src/render/filter.ts`, `src/render/summary.ts` | — | +| Path filter (`/regex/`) doesn't match expected | `src/render/filter-match.ts` | `src/tui.ts` (filter state) | +| API returns 0 results or stops paginating | `src/api.ts` | `src/api-utils.ts` | +| Rate limit hit / 429 not retried | `src/api-utils.ts` (`fetchWithRetry`) | — | +| TUI shows blank screen or wrong row | `src/tui.ts` | `src/render/rows.ts` | +| Mouse clicks land on the wrong row/zone | `src/render/mouse-hit.ts` | `src/tui.ts` (click dispatch) | +| Mouse escape sequences not parsed / terminal stuck in mouse mode | `src/render/mouse.ts` | `src/tui.ts` (enable/disable mouse reporting) | +| Wheel scroll wrong step size or accidental clicks after scrolling | `src/render/layout-constants.ts` (`MOUSE_SCROLL_STEP`) | `src/scroll-cooldown.ts` | +| Help overlay doesn't appear / has wrong keys | `src/render.ts` (`renderHelpOverlay`) | `src/tui.ts` | +| Upgrade fails or replaces wrong binary | `src/upgrade.ts` | — | +| Completion script wrong content | `src/completions.ts` | — | +| Completion file written to wrong path | `src/completions.ts` (`getCompletionFilePath`) | env vars (`XDG_*`, `ZDOTDIR`) | +| Completion not refreshed after upgrade | `src/upgrade.ts` (`refreshCompletions`) | — | +| `--version` shows wrong info | `build.ts` (SHA injection) | — | +| CLI option ignored or parsed wrong | `github-code-search.ts` | `src/types.ts` (`OutputType`) | --- diff --git a/.github/skills/feature.md b/.github/skills/feature.md index 5768253..a45840f 100644 --- a/.github/skills/feature.md +++ b/.github/skills/feature.md @@ -15,6 +15,8 @@ github-code-search.ts CLI (Commander) — parsing, program flow, output pipe │ └── src/cache.ts Disk cache for team list (used only by api.ts) │ ├── src/tui.ts Interactive TTY — the only allowed stdin/stdout I/O +│ └── src/scroll-cooldown.ts Pure: scroll-cooldown state machine (debounces clicks +│ during trackpad momentum scrolling) │ ├── src/aggregate.ts Pure: filter + exclusion logic ├── src/group.ts Pure: team-prefix grouping @@ -27,7 +29,12 @@ github-code-search.ts CLI (Commander) — parsing, program flow, output pipe │ ├── filter-match.ts Pure: makeExtractMatcher, makeRepoMatcher │ ├── rows.ts Pure: buildRows, rowTerminalLines, isCursorVisible │ ├── summary.ts Pure: buildSummary, buildSummaryFull, buildSelectionSummary -│ └── selection.ts Pure: applySelectAll, applySelectNone +│ ├── selection.ts Pure: applySelectAll, applySelectNone +│ ├── layout-constants.ts Pure: header-line counts, mouse button codes, MOUSE_SCROLL_STEP +│ ├── mouse.ts Pure: parseMouseEvent() — SGR mouse-escape-sequence parser +│ │ (imported directly by `tui.ts`, not re-exported from `render.ts`) +│ └── mouse-hit.ts Pure: hitTestClick() — maps a click + scrollOffset to a Row/zone +│ (imported directly by `tui.ts`, not re-exported from `render.ts`) │ └── src/types.ts Single source of truth for all shared interfaces ``` diff --git a/AGENTS.md b/AGENTS.md index bd74dc6..c2abbd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,8 +86,11 @@ src/ # returns RegExp for local client-side filtering — no I/O render.ts # Façade re-exporting sub-modules + top-level # renderGroups() / renderHelpOverlay() - tui.ts # Interactive keyboard-driven UI (navigation, filter mode, - # help overlay, selection) + scroll-cooldown.ts # Pure scroll-cooldown state machine (createScrollCooldownState, + # isScrollCooldownActive, updateScrollCooldown) — debounces + # clicks during trackpad momentum scrolling — no I/O + tui.ts # Interactive keyboard- and mouse-driven UI (navigation, filter + # mode, help overlay, selection, SGR mouse tracking) output.ts # Text (markdown) and JSON output formatters upgrade.ts # Auto-upgrade logic (fetch latest GitHub release, replace binary) # + refreshCompletions() — overwrites existing completion file @@ -102,6 +105,11 @@ src/ summary.ts # buildSummary, buildSummaryFull, buildSelectionSummary selection.ts # applySelectAll, applySelectNone team-pick.ts # renderTeamPickHeader — pick-mode candidate bar (pure, no I/O) + layout-constants.ts # Shared header-line counts, mouse button codes and + # MOUSE_SCROLL_STEP, hit-test column math — no I/O + mouse.ts # Pure SGR mouse-escape-sequence parser: parseMouseEvent() — no I/O + mouse-hit.ts # Pure hit-testing: hitTestClick() maps a click (x, y) + scrollOffset + # to a Row and the zone that was clicked — no I/O *.test.ts # Unit tests co-located with source files test-setup.ts # Global test setup (Bun preload) @@ -111,7 +119,7 @@ src/ - **Pure functions first.** All business logic lives in pure, side-effect-free functions (`aggregate.ts`, `group.ts`, `output.ts`, `render/` sub-modules). This makes them straightforward to unit-test. - **Side effects are isolated.** API calls (`api.ts`, `api-utils.ts`), TTY interaction (`tui.ts`) and CLI parsing (`github-code-search.ts`) are the only side-effectful surfaces. `api-utils.ts` hosts shared retry/pagination helpers that perform network I/O and must not be used outside `api.ts`. `cache.ts` hosts disk-cache helpers that perform filesystem I/O and must not be used outside `api.ts`. -- **`render.ts` is a façade.** It re-exports everything from `render/` and adds two top-level rendering functions. Consumers import from `render.ts`, not directly from sub-modules. +- **`render.ts` is a façade.** It re-exports everything from `render/` and adds two top-level rendering functions. Consumers import from `render.ts`, not directly from sub-modules. Exceptions: `render/team-pick.ts`, `render/mouse.ts` and `render/mouse-hit.ts` are pure modules imported **directly** by their sole consumer (`render.ts` for `team-pick.ts`, `tui.ts` for the mouse modules) and are not re-exported publicly (knip would flag unused re-exports otherwise). - **`render/terminal.ts` is the sole Bun API call site.** All calls to `Bun.stringWidth()`, `Bun.stripANSI()`, and `Bun.sliceAnsi()` must go through the `terminal.ts` wrapper functions (`visibleWidth()`, `stripAnsi()`, `clipToWidth()`, `hasAnsi()`). This centralizes terminal handling logic and makes it easy to verify correct Unicode handling (graphemes, emoji, CJK, ZWJ sequences). - **`types.ts` is the single source of truth** for all shared interfaces. Any new shared type must go there. - **No classes** — the codebase uses plain TypeScript interfaces and functions throughout. @@ -263,3 +271,4 @@ For minor/major releases update `docs/blog/index.md` to add a row in the version - The `--pick-team` option is repeatable (Commander collect function); each assignment resolves one combined section label to a single team. A warning is emitted on stderr when a label is not found. - `src/render/team-pick.ts` is a pure module (no I/O) and must be consumed only via the `src/render.ts` façade — it is imported **directly** inside `render.ts` for internal use but is not re-exported publicly (knip would flag it). - `RepoGroup.pickedFrom` (optional field in `src/types.ts`) tracks the combined label a repo was moved from; future split-mode features will use this to offer re-assignment. +- **Mouse support** uses the terminal's SGR mouse-reporting protocol (`\x1b[?1000h\x1b[?1006h`), enabled in `runInteractive()` (`tui.ts`) next to `process.stdin.setRawMode(true)` and disabled on **every** exit path (normal exit, `q`, Ctrl+C, unhandled error) so a crashed process never leaves the user's terminal stuck in mouse-reporting mode. Escape sequences are parsed by `parseMouseEvent()` (`src/render/mouse.ts`), clicks are mapped to a row/zone by `hitTestClick()` (`src/render/mouse-hit.ts`), and wheel scroll steps use `MOUSE_SCROLL_STEP` (`src/render/layout-constants.ts`). `src/scroll-cooldown.ts` debounces clicks for a short window after a wheel scroll to avoid accidental selection during trackpad momentum scrolling. Documented for users in `docs/reference/keyboard-shortcuts.md` § Mouse support and `docs/usage/interactive-mode.md`. diff --git a/README.md b/README.md index 8204bfc..5344269 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Latest release](https://img.shields.io/github/v/release/fulll/github-code-search)](https://github.com/fulll/github-code-search/releases/latest) Interactive CLI to search GitHub code across an organization — per-repository aggregation, -keyboard-driven TUI, fine-grained extract selection, markdown/JSON output. +keyboard- and mouse-driven TUI, fine-grained extract selection, markdown/JSON output. → **Full documentation: https://fulll.github.io/github-code-search/** @@ -39,7 +39,7 @@ github-code-search query "TODO" --org my-org - **Org-wide search** — queries all repositories in a GitHub organization in one command, with automatic pagination up to 1 000 results - **Per-repository aggregation** — results grouped by repo, not as a flat list; fold/unfold each repo to focus on what matters -- **Keyboard-driven TUI** — navigate with arrow keys, toggle selections, filter by file path, confirm with Enter — without leaving the terminal +- **Keyboard- and mouse-driven TUI** — navigate with arrow keys or mouse clicks, toggle selections, scroll with the wheel, filter by file path, confirm with Enter — without leaving the terminal - **Fine-grained selection** — pick exactly the repos and extracts you want; deselected items are recorded as exclusions in the replay command - **Structured output** — Markdown document with a `# Results for` query heading, GitHub deeplinks and the exact matched token per extract; or machine-readable JSON that, when segment data is available, includes `matchedText`, `line` and `col` fields — ready to paste into docs, issues or scripts - **Team-prefix grouping** — group results by team prefix (e.g. `platform/`, `data/`) using `--group-by-team-prefix` @@ -109,16 +109,16 @@ Use `/pattern/` syntax to run a regex search. The CLI automatically derives a sa The official [`gh` CLI](https://cli.github.com/) does support `gh search code`, but it returns a **flat paginated list** — one result per line, no grouping, no interactive selection, no structured output. -| | `gh search code` | `github-code-search` | -| ------------------------------------------ | :--------------: | :------------------: | -| Results grouped by repo | ✗ | ✓ | -| Interactive TUI (navigate, select, filter) | ✗ | ✓ | -| Fine-grained extract selection | ✗ | ✓ | -| Markdown / JSON output | ✗ | ✓ | -| Replay / CI command | ✗ | ✓ | -| Team-prefix grouping | ✗ | ✓ | -| Regex search | ✗ | ✓ | -| Syntax highlighting in terminal | ✗ | ✓ | -| Pagination (up to 1 000 results) | ✓ | ✓ | +| | `gh search code` | `github-code-search` | +| --------------------------------------------------------- | :--------------: | :------------------: | +| Results grouped by repo | ✗ | ✓ | +| Interactive TUI (navigate, select, filter, mouse support) | ✗ | ✓ | +| Fine-grained extract selection | ✗ | ✓ | +| Markdown / JSON output | ✗ | ✓ | +| Replay / CI command | ✗ | ✓ | +| Team-prefix grouping | ✗ | ✓ | +| Regex search | ✗ | ✓ | +| Syntax highlighting in terminal | ✗ | ✓ | +| Pagination (up to 1 000 results) | ✓ | ✓ | `github-code-search` is purpose-built for **org-wide code audits and interactive triage** — not just a search wrapper. diff --git a/docs/.vitepress/theme/ComparisonTable.vue b/docs/.vitepress/theme/ComparisonTable.vue index 08b4fae..71c562f 100644 --- a/docs/.vitepress/theme/ComparisonTable.vue +++ b/docs/.vitepress/theme/ComparisonTable.vue @@ -19,7 +19,7 @@ const ROWS: Row[] = [ }, { feature: "Interactive TUI \u2014 navigate, select, filter", - desc: "Arrow-key navigation, path-based filter and live selection without leaving the terminal.", + desc: "Keyboard and mouse support: arrow keys or clicks for navigation, path-based filter, live selection — all without leaving the terminal.", gh: false, gcs: true, docLink: "/usage/interactive-mode", diff --git a/docs/.vitepress/theme/HowItWorks.vue b/docs/.vitepress/theme/HowItWorks.vue index a42498b..5ff9ef5 100644 --- a/docs/.vitepress/theme/HowItWorks.vue +++ b/docs/.vitepress/theme/HowItWorks.vue @@ -72,8 +72,9 @@ Step 2

Triage interactively

- A keyboard-driven TUI opens. Navigate repos, expand extracts, filter by file path. - Select exactly what matters — deselect noise. Works without leaving the terminal. + A keyboard and mouse-driven TUI opens (mouse works in compatible terminals). Navigate + repos, expand extracts, filter by file path. Select exactly what matters — deselect + noise. Works without leaving the terminal.

diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 0c7d995..e4d6ff6 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -105,20 +105,22 @@ C4Component ## Component descriptions -| Component | Source file | Key exports | -| ------------------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Filter & aggregation** | `src/aggregate.ts` | `aggregate()` — filters `CodeMatch[]` by repository and extract exclusion lists; normalises both `repoName` and `org/repoName` forms. | -| **Team grouping** | `src/group.ts` | `groupByTeamPrefix()` — groups `RepoGroup[]` into `TeamSection[]` keyed by team slug; `flattenTeamSections()` — converts back to a flat list for the TUI row builder; `applyTeamPick()` — moves repos from a combined section to a chosen team section; `rebuildTeamSections()` — reconstructs `TeamSection[]` from a flat list (used by TUI pick mode). | -| **Shell completions** | `src/completions.ts` | `generateCompletion(shell)` — returns the full bash/zsh/fish completion script; `detectShell()` — reads `$SHELL`; `getCompletionFilePath(shell, opts)` — resolves the XDG-aware installation path. | -| **Row builder** | `src/render/rows.ts` | `buildRows()` — converts `RepoGroup[]` into `Row[]` filtered by the active target (path / content / repo); `rowTerminalLines()` — measures wrapped height; `isCursorVisible()` — viewport clipping. | -| **Summary builder** | `src/render/summary.ts` | `buildSummary()` — compact header line; `buildSummaryFull()` — detailed counts; `buildSelectionSummary()` — "N files selected" footer. | -| **Filter stats** | `src/render/filter.ts` | `buildFilterStats()` — produces the `FilterStats` object (visible repos, files, matches) used by the TUI filter bar live counter. | -| **Pattern matchers** | `src/render/filter-match.ts` | `makeExtractMatcher()` — builds a case-insensitive substring or RegExp test function for path or content targets; `makeRepoMatcher()` — wraps the same logic for repo-name matching. | -| **Selection helpers** | `src/render/selection.ts` | `applySelectAll()` — marks all visible rows as selected (respects filter target); `applySelectNone()` — deselects all visible rows. | -| **Syntax highlighter** | `src/render/highlight.ts` | `highlightFragment()` — maps file extension to a language token ruleset and applies ANSI escape sequences. Falls back to plain text for unknown extensions. | -| **Team pick bar** | `src/render/team-pick.ts` | `renderTeamPickHeader()` — renders the ANSI pick-mode candidate bar shown when the user presses `p` on a multi-team section header. Focused candidate is highlighted in bold magenta; others are dimmed. | -| **Terminal API wrapper** | `src/render/terminal.ts` | `visibleWidth()` — measures terminal columns (Bun.stringWidth); `stripAnsi()` — removes escape codes (Bun.stripANSI); `clipToWidth()` — truncates to N columns preserving partial reset (Bun.sliceAnsi); `hasAnsi()` — detects presence of codes. Sole authorized call site for Bun 1.4+ ANSI APIs. | -| **Output formatter** | `src/output.ts` | `buildOutput()` — entry point for both `--format markdown` and `--format json` serialisation of the confirmed selection. | +| Component | Source file | Key exports | +| ------------------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Filter & aggregation** | `src/aggregate.ts` | `aggregate()` — filters `CodeMatch[]` by repository and extract exclusion lists; normalises both `repoName` and `org/repoName` forms. | +| **Team grouping** | `src/group.ts` | `groupByTeamPrefix()` — groups `RepoGroup[]` into `TeamSection[]` keyed by team slug; `flattenTeamSections()` — converts back to a flat list for the TUI row builder; `applyTeamPick()` — moves repos from a combined section to a chosen team section; `rebuildTeamSections()` — reconstructs `TeamSection[]` from a flat list (used by TUI pick mode). | +| **Shell completions** | `src/completions.ts` | `generateCompletion(shell)` — returns the full bash/zsh/fish completion script; `detectShell()` — reads `$SHELL`; `getCompletionFilePath(shell, opts)` — resolves the XDG-aware installation path. | +| **Layout constants** | `src/render/layout-constants.ts` | `getHeaderLines()` — computes visible header height based on filter mode and active filter state; Mouse button constants: `MOUSE_BUTTON_WHEEL_UP` (64), `MOUSE_BUTTON_WHEEL_DOWN` (65); Column zones: `FOLD_COLUMN_START/END`, `CHECKBOX_COLUMN_START/END`, `NAV_COLUMN_START`; Zone helpers: `isClickInFoldZone()`, `isClickInCheckboxZone()`, `isClickInNavZone()`. | +| **Row builder** | `src/render/rows.ts` | `buildRows()` — converts `RepoGroup[]` into `Row[]` filtered by the active target (path / content / repo); `rowTerminalLines()` — measures wrapped height; `isCursorVisible()` — viewport clipping. | +| **Mouse hit-test** | `src/render/mouse-hit.ts` | `hitTestClick()` — maps terminal coordinates (x, y) to logical row and action (fold, select, navigate); handles full-width checkbox zone. (Double-click detection is implemented separately in `src/tui.ts` via timestamp tracking.) | +| **Summary builder** | `src/render/summary.ts` | `buildSummary()` — compact header line; `buildSummaryFull()` — detailed counts; `buildSelectionSummary()` — "N files selected" footer. | +| **Filter stats** | `src/render/filter.ts` | `buildFilterStats()` — produces the `FilterStats` object (visible repos, files, matches) used by the TUI filter bar live counter. | +| **Pattern matchers** | `src/render/filter-match.ts` | `makeExtractMatcher()` — builds a case-insensitive substring or RegExp test function for path or content targets; `makeRepoMatcher()` — wraps the same logic for repo-name matching. | +| **Selection helpers** | `src/render/selection.ts` | `applySelectAll()` — marks all visible rows as selected (respects filter target); `applySelectNone()` — deselects all visible rows. | +| **Syntax highlighter** | `src/render/highlight.ts` | `highlightFragment()` — maps file extension to a language token ruleset and applies ANSI escape sequences. Falls back to plain text for unknown extensions. | +| **Team pick bar** | `src/render/team-pick.ts` | `renderTeamPickHeader()` — renders the ANSI pick-mode candidate bar shown when the user presses `p` on a multi-team section header. Focused candidate is highlighted in bold magenta; others are dimmed. | +| **Terminal API wrapper** | `src/render/terminal.ts` | `visibleWidth()` — measures terminal columns (Bun.stringWidth); `stripAnsi()` — removes escape codes (Bun.stripANSI); `clipToWidth()` — truncates to N columns preserving partial reset (Bun.sliceAnsi); `hasAnsi()` — detects presence of codes. Sole authorized call site for Bun 1.4+ ANSI APIs. | +| **Output formatter** | `src/output.ts` | `buildOutput()` — entry point for both `--format markdown` and `--format json` serialisation of the confirmed selection. | ## Design principles @@ -126,3 +128,72 @@ C4Component - **Single responsibility.** Each component owns exactly one concern (rows, summary, selection, …). The TUI composes them at render time rather than duplicating logic. - **`types.ts` as the contract.** All components share the interfaces defined in `src/types.ts` (`TextMatchSegment`, `TextMatch`, `CodeMatch`, `RepoGroup`, `Row`, `TeamSection`, `OutputFormat`, `OutputType`, `FilterTarget`). Changes to these types require updating all components. - **`render.ts` as façade.** External consumers import from `src/render.ts`, which re-exports all symbols from the `src/render/` sub-modules plus the top-level `renderGroups()` and `renderHelpOverlay()` functions. `renderTeamPickHeader` is consumed internally by `render.ts` and is not re-exported (it is not part of the public façade). + +## Mouse interaction model + +The TUI supports mouse input via the terminal's SGR (Select-Graphic-Rendition) protocol, which sends click and scroll events as terminal escape sequences. Mouse and keyboard shortcuts are fully complementary: every mouse action has a keyboard equivalent. + +### Protocol and coordinate mapping + +- **Terminal protocol**: SGR 1006 extends basic mouse reporting (`?1000`) with extended button codes for wheel events and 8-bit-clean 1-indexed coordinates. +- **Button codes**: 0 = left click, 1 = middle, 2 = right, 64 = wheel up, 65 = wheel down. +- **Coordinate origin**: Terminal coordinates are 1-indexed from the top-left; the TUI converts them to 0-indexed logical row indices via `clickedLineOffset = y - 1 - headerLines`. +- **Header height**: The header (title, summary, filter bar) consumes 4–6 terminal rows depending on filter mode. `getHeaderLines()` computes this dynamically. + +### Click zones and hit-testing + +The TUI divides each row into three non-overlapping zones for different interactions: + +**Repo rows** (`▸ ✓ repo-name`): + +1. **Fold zone** (columns 1–2): Double-click toggles the fold state (show/hide extracts). +2. **Navigation zone** (column 3): Single-click only (move cursor). No double-click action. +3. **Checkbox zone** (columns 4+, full-width): Double-click toggles repo selection (cascades to all extracts). + +**Extract rows** (` ✓ path:line:col`): + +1. **Navigation zone** (columns 1–3): Single-click only (move cursor). +2. **Checkbox zone** (columns 4+, full-width): Double-click toggles extract selection. + +Each zone occupies visual terminal columns; emoji characters (▸, ✓) are 2 columns wide. Zone boundaries are defined in `layout-constants.ts`: + +``` +Repo row visual layout: +Column: 1 2 3 4 5 6 7 ... +Content: ▸ │ ✓ r e p o - n a m e +Zone: └fold─┘ nav └─────checkbox─────┘ +``` + +The full-width checkbox zone (from column 4 to screen edge) enables natural double-click selection anywhere on the row content, not just on the checkbox emoji. + +### Double-click detection + +Double-clicks are detected by comparing: + +1. The focused row (via `getRowKey()` which combines row type and indices). +2. The time delta: less than 300 ms since the previous click on the same row. + +If both conditions are met, the double-click action is executed (fold or select); otherwise, the click is treated as a single navigation action. + +### Scroll and cooldown + +Wheel scroll events are processed by a state machine in `scroll-cooldown.ts`: + +1. **Scroll request**: Wheel up/down updates `scrollOffset` by `MOUSE_SCROLL_STEP` (3 rows). +2. **Cooldown window**: A 600 ms timer starts; all clicks are ignored during this period. +3. **Momentum scrolling**: On trackpads (macOS, Linux), scroll gestures decelerate after the wheel event. The cooldown prevents accidental clicks during this deceleration phase. + +### Mouse event parsing + +Mouse escape sequences are parsed in `src/render/mouse.ts` via `parseMouseEvent()`, which extracts button code and coordinates. Non-mouse input is passed to the normal keyboard handler. + +### Integration with TUI state + +The TUI state machine (`src/tui.ts`) integrates mouse events into the redraw loop: + +1. Parse the mouse event (or keyboard input). +2. If mouse: check scroll cooldown and hit-test the click against visible rows. +3. Compute the appropriate action and update state (cursor position, selection state, fold state). +4. Redraw the screen. + +All rendering state (row visibility, cursor position, selection) is independent of mouse vs. keyboard input: the view is always consistent regardless of how the user interacts with the TUI. diff --git a/docs/index.md b/docs/index.md index ed311a9..9be4df9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,8 +27,8 @@ features: details: Results are grouped by repository, not shown as a flat list. Fold or unfold each repo to focus on what matters. - icon: src: /icons/terminal.svg - title: Keyboard-driven TUI - details: Navigate with arrow keys, select individual extracts, filter by file path, and confirm with Enter — all without leaving the terminal. + title: Keyboard and mouse-driven TUI + details: Navigate with arrow keys or mouse clicks, select individual extracts, filter by file path, and confirm with Enter — all without leaving the terminal. - icon: src: /icons/target.svg title: Fine-grained selection diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index d530895..3215721 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -102,3 +102,56 @@ When re-pick mode is active (after pressing `t` on a picked repo marked `◈`): | `h` / `?` | Toggle the help overlay (shows all key bindings) | | `Enter` | When help overlay is **closed**: confirm and print selected results. When **open**: close the overlay. | | `q` / `Ctrl+C` | Quit without printing results | + +## Mouse support + +The interactive TUI supports mouse interaction via the terminal's SGR (Select-Graphic-Rendition) mouse protocol, enabling click-based navigation and selection alongside keyboard shortcuts. + +### Scroll wheel + +| Action | Effect | +| ---------------------- | ----------------------------------------------------- | +| Scroll up / down wheel | Scroll the viewport up or down by 3 rows | +| Scroll during momentum | Clicks are ignored during trackpad momentum scrolling | + +During trackpad momentum scrolling (macOS, Linux), the TUI enters a brief "scroll cooldown" period to ignore accidental clicks while the scroll is still decelerating. This prevents unintended selections. + +### Click actions + +| Action | Effect | +| ------------------------------------------------------ | ------------------------------------------------------------- | +| **Single-click on row** (anywhere) | Move the cursor to that row (navigation) | +| **Double-click on row** | Perform the double-click action for that row type (see below) | +| **Double-click on fold icon** (▸/▾, repo rows) | Toggle repo fold state (show/hide extracts) | +| **Double-click on checkbox or row content** (repos) | Toggle the repo's selection state (cascades to all extracts) | +| **Double-click on checkbox or row content** (extracts) | Toggle that extract's selection state | + +### Click zones + +The TUI divides each row into interactive zones: + +**Repo rows** (`"▸ ✓ repo-name"`): + +| Zone | Columns | Action on double-click | +| ------------- | ------- | ----------------------------------------- | +| Fold control | 1–2 | Toggle fold (show/hide extracts) | +| Checkbox zone | 4+ | Toggle repo selection (full-width double) | +| Navigation | 3 | Single-click only (move cursor) | + +**Extract rows** (`" ✓ path:line:col"`): + +| Zone | Columns | Action on double-click | +| ------------- | ------- | ------------------------------------- | +| Indent | 1–3 | Single-click only (move cursor) | +| Checkbox zone | 4+ | Toggle extract selection (full-width) | + +Note: All double-click actions are mapped to the row type (repo or extract) and the click column. Clicking anywhere from column 4 onwards (checkbox start) triggers a selection action; clicking on columns 1–3 only triggers navigation or fold (repo rows only). + +### Relationship with keyboard + +Mouse and keyboard shortcuts are fully complementary: + +- **Selection**: Mouse double-click on checkbox = Spacebar `Space` +- **Navigation**: Mouse single-click = Arrow keys `↑` / `↓` +- **Fold control**: Mouse double-click on fold icon = Arrow keys `←` / `→` +- **Select all / Select none**: Keyboard `a` / `n` (no mouse equivalent) diff --git a/docs/usage/interactive-mode.md b/docs/usage/interactive-mode.md index e883bcd..c3906bc 100644 --- a/docs/usage/interactive-mode.md +++ b/docs/usage/interactive-mode.md @@ -48,6 +48,43 @@ github-code-search "useFeatureFlag" --org fulll | `Enter` | Confirm and print selected results (also closes the help overlay) | | `q` / `Ctrl+C` | Quit without printing | +## Mouse support + +The TUI supports mouse interaction via the terminal's SGR mouse protocol. You can navigate, select, and fold repos using your mouse alongside keyboard shortcuts. + +### Scrolling + +- **Scroll wheel** (up/down) — move the viewport up or down by 3 rows. +- **Momentum scrolling** (trackpad): clicks are ignored during and immediately after the scroll gesture to prevent accidental selections while the scroll is still decelerating. + +### Single-click + +- **Single-click on any row** — move the cursor to that row (equivalent to `↑`/`↓` navigation). + +### Double-click + +Double-click actions depend on both the **row type** (repo or extract) and the **click zone** (columns 1–3 vs. columns 4+): + +**On a repo row** (`▸ ✓ repo-name`): + +| Click zone | Action | +| -------------------------------- | ------------------------------- | +| Fold control (columns 1–2) | Toggle fold (`←` / `→`) | +| Checkbox or content (columns 4+) | Toggle repo selection (`Space`) | + +**On an extract row** (` ✓ path:line:col`): + +| Click zone | Action | +| ---------------------------------- | ----------------------------- | +| Content area (columns 4+) | Toggle extract selection | +| Navigation-only zone (columns 1–3) | No action (single-click only) | + +The checkbox zone (column 4 and beyond) is **full-width for selection**, so you can double-click anywhere on the row content to toggle selection. + +### Accessibility note + +Mouse support is optional; all features are fully accessible via keyboard. Many users prefer the keyboard-only workflow for speed and precision during large searches. + ## Selection behaviour - **Selecting a repo row** (`Space`) cascades to all its extracts. diff --git a/src/render.test.ts b/src/render.test.ts index 3e2806a..7d893f3 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -628,6 +628,20 @@ describe("renderGroups", () => { expect(stripped).toContain("\u25be"); }); + it("shows dimmed ✓ for deselected repo", () => { + const groups = [makeGroup("org/repoA", ["src/a.ts"], true)]; + groups[0].repoSelected = false; + // When repo is deselected, all extracts should be deselected too + groups[0].extractSelected = groups[0].extractSelected.map(() => false); + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org"); + // Verify the output contains the ✓ character + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("✓"); + // Verify the ✓ is styled with ANSI dim code (pc.dim uses \x1b[2m) + expect(out).toMatch(/\x1b\[2m✓/); + }); + it("shows sticky repo header when extract cursor scrolled past its repo", () => { // rows: [repo(0), ext(0,0), ext(0,1), ext(0,2)] // cursor=3, scrollOffset=2 → repo row (idx 0) < scrollOffset → sticky diff --git a/src/render.ts b/src/render.ts index 9441fb9..3db9027 100644 --- a/src/render.ts +++ b/src/render.ts @@ -10,6 +10,12 @@ import { visibleWidth, stripAnsi, clipToWidth } from "./render/terminal.ts"; // ─── Re-exports ─────────────────────────────────────────────────────────────── // Consumers (tui.ts, output.ts, tests) continue to import from render.ts. +export { + getHeaderLines, + MOUSE_BUTTON_WHEEL_UP, + MOUSE_BUTTON_WHEEL_DOWN, + MOUSE_SCROLL_STEP, +} from "./render/layout-constants.ts"; export { highlightFragment } from "./render/highlight.ts"; export { buildFilterStats, type FilterStats } from "./render/filter.ts"; export { @@ -564,7 +570,9 @@ export function renderGroups( if (row.type === "repo") { const arrow = group.folded ? pc.magenta("▸") : pc.magenta("▾"); - // Green ✓ for selected; dimmed ✓ for deselected — provides a clickable target in both states. + // Determine checkbox state: green if any extract is selected, dimmed if none are selected. + // group.repoSelected is kept in sync with extracts via tui.ts and render/selection.ts, + // so we can use it directly without recomputing. const checkbox = group.repoSelected ? pc.green("✓") : pc.dim("✓"); // On cursor rows, use bold+white for the repo name (dark bg applied // to the whole line via renderActiveLine; no inline bgMagenta needed). diff --git a/src/render/layout-constants.test.ts b/src/render/layout-constants.test.ts new file mode 100644 index 0000000..6e74bab --- /dev/null +++ b/src/render/layout-constants.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "bun:test"; +import { + BASE_HEADER_LINES, + FILTER_BAR_LINES_NORMAL, + FILTER_BAR_LINES_ACTIVE, + getHeaderLines, + FOLD_COLUMN_START, + FOLD_COLUMN_END, + CHECKBOX_COLUMN_START, + CHECKBOX_COLUMN_END, + NAV_COLUMN_START, + isClickInFoldZone, + isClickInCheckboxZone, + isClickInNavZone, +} from "./layout-constants.ts"; + +describe("render/layout-constants", () => { + describe("header lines calculation", () => { + it("should return base header lines when no filter is active", () => { + expect(getHeaderLines(false, false)).toBe(BASE_HEADER_LINES); + expect(getHeaderLines(false, false)).toBe(4); + }); + + it("should add filter bar lines when in normal filter mode", () => { + expect(getHeaderLines(false, true)).toBe(BASE_HEADER_LINES + FILTER_BAR_LINES_NORMAL); + expect(getHeaderLines(false, true)).toBe(5); + }); + + it("should add active filter bar lines when in filter input mode", () => { + expect(getHeaderLines(true, false)).toBe(BASE_HEADER_LINES + FILTER_BAR_LINES_ACTIVE); + expect(getHeaderLines(true, false)).toBe(6); + }); + + it("should prefer active filter mode over normal when both conditions present", () => { + // In filter input mode, filterMode=true takes precedence + expect(getHeaderLines(true, true)).toBe(BASE_HEADER_LINES + FILTER_BAR_LINES_ACTIVE); + expect(getHeaderLines(true, true)).toBe(6); + }); + }); + + describe("fold zone detection", () => { + it("should detect fold zone at columns 1-2", () => { + expect(isClickInFoldZone(1)).toBe(true); + expect(isClickInFoldZone(2)).toBe(true); + }); + + it("should not detect fold zone outside columns 1-2", () => { + expect(isClickInFoldZone(0)).toBe(false); + expect(isClickInFoldZone(3)).toBe(false); + expect(isClickInFoldZone(4)).toBe(false); + }); + }); + + describe("checkbox zone detection", () => { + it("should detect checkbox zone at columns 4-5", () => { + expect(isClickInCheckboxZone(4)).toBe(true); + expect(isClickInCheckboxZone(5)).toBe(true); + }); + + it("should detect checkbox zone beyond column 5 (full-width double-click)", () => { + // Checkbox zone now extends from column 4 to infinity (full width from checkbox start) + expect(isClickInCheckboxZone(6)).toBe(true); + expect(isClickInCheckboxZone(100)).toBe(true); + }); + + it("should not detect checkbox zone before column 4", () => { + expect(isClickInCheckboxZone(3)).toBe(false); + }); + }); + + describe("navigation zone detection", () => { + it("should detect nav zone at column 6 and beyond", () => { + expect(isClickInNavZone(6)).toBe(true); + expect(isClickInNavZone(7)).toBe(true); + expect(isClickInNavZone(100)).toBe(true); + }); + + it("should not detect nav zone before column 6", () => { + expect(isClickInNavZone(5)).toBe(false); + expect(isClickInNavZone(1)).toBe(false); + }); + }); + + describe("column constants", () => { + it("should have non-overlapping zones", () => { + // Fold and checkbox should not overlap + expect(FOLD_COLUMN_END).toBeLessThan(CHECKBOX_COLUMN_START); + // Checkbox and nav should not overlap + expect(CHECKBOX_COLUMN_END).toBeLessThan(NAV_COLUMN_START); + }); + + it("should have sensible column ranges", () => { + expect(FOLD_COLUMN_START).toBe(1); + expect(FOLD_COLUMN_END).toBe(2); + expect(CHECKBOX_COLUMN_START).toBe(4); + expect(CHECKBOX_COLUMN_END).toBe(5); + expect(NAV_COLUMN_START).toBe(6); + }); + }); +}); diff --git a/src/render/layout-constants.ts b/src/render/layout-constants.ts new file mode 100644 index 0000000..ec18862 --- /dev/null +++ b/src/render/layout-constants.ts @@ -0,0 +1,74 @@ +/** + * Rendering constants for TUI layout and mouse hit-testing. + * Centralizes all hard-coded measurements so they're defined once and reused consistently. + */ + +// ─── Header Layout ───────────────────────────────────────────────────────────── +// Base header layers: title (1) + summary (1) + hints (1) + blank (1) +export const BASE_HEADER_LINES = 4; + +// Additional header lines when filter bar is shown +export const FILTER_BAR_LINES_NORMAL = 1; // Filter status or mode badge line +export const FILTER_BAR_LINES_ACTIVE = 2; // Filter input + hints in filter mode + +/** + * Calculate total header lines before viewport content. + * Accounts for base header + filter bar presence. + */ +export function getHeaderLines(filterMode: boolean, hasActiveFilter: boolean): number { + let total = BASE_HEADER_LINES; + if (filterMode) { + total += FILTER_BAR_LINES_ACTIVE; + } else if (hasActiveFilter) { + total += FILTER_BAR_LINES_NORMAL; + } + return total; +} + +// ─── Mouse Button Codes (SGR mouse protocol) ───────────────────────────────── +// https://en.wikipedia.org/wiki/X11_mouse_protocol#SGR_1006_Protocol +export const MOUSE_BUTTON_WHEEL_UP = 64; +export const MOUSE_BUTTON_WHEEL_DOWN = 65; +export const MOUSE_SCROLL_STEP = 3; // rows per wheel scroll + +// ─── Mouse Hit-Testing: Column Layout ────────────────────────────────────────── +// Terminal row columns (1-indexed per SGR protocol): +// Repo row: "▸ ✓ repo-name" +// Extract: " ✓ path:line:col" +// +// Emoji widths: ▸, ▾, ✓ each occupy 2 visual columns in the terminal. + +// Fold control (▸/▾ emoji) — left zone on repo rows only +export const FOLD_COLUMN_START = 1; +export const FOLD_COLUMN_END = 2; + +// Checkbox (✓ emoji) — appears on both repo and extract rows +export const CHECKBOX_COLUMN_START = 4; +export const CHECKBOX_COLUMN_END = 5; + +// Navigation zone — everything else +export const NAV_COLUMN_START = 6; + +/** + * Determine if a click at column `x` is in the fold zone (repo row control). + * Repo rows: fold icon (columns 1-2), separator (column 3), checkbox (columns 4-5), etc. + */ +export function isClickInFoldZone(x: number): boolean { + return x >= FOLD_COLUMN_START && x <= FOLD_COLUMN_END; +} + +/** + * Determine if a click at column `x` is in the checkbox/select zone. + * Checkbox emoji starts at column 4; double-click works on entire row from there. + */ +export function isClickInCheckboxZone(x: number): boolean { + return x >= CHECKBOX_COLUMN_START; +} + +/** + * Determine if a click at column `x` is in the navigation/main content zone. + * This is the zone where clicking moves the cursor without selecting. + */ +export function isClickInNavZone(x: number): boolean { + return x >= NAV_COLUMN_START; +} diff --git a/src/render/mouse-hit.test.ts b/src/render/mouse-hit.test.ts index 3f5eff5..7a03ac2 100644 --- a/src/render/mouse-hit.test.ts +++ b/src/render/mouse-hit.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "bun:test"; import { hitTestClick } from "./mouse-hit.ts"; +import { + FOLD_COLUMN_START, + FOLD_COLUMN_END, + CHECKBOX_COLUMN_START, + CHECKBOX_COLUMN_END, + NAV_COLUMN_START, +} from "./layout-constants.ts"; import type { RepoGroup, Row } from "../types.ts"; function createTestGroup(name: string, repoSelected = true): RepoGroup { @@ -24,92 +31,229 @@ describe("hitTestClick", () => { it("returns null for click out of bounds (y below rows)", () => { const groups = [createTestGroup("org/repo")]; const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 0, 100); + const result = hitTestClick(groups, rows, 0, 1, 100); expect(result).toBeNull(); }); it("returns null for click out of bounds (y at 0)", () => { const groups = [createTestGroup("org/repo")]; const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 0, 0); + const result = hitTestClick(groups, rows, 0, 1, 0); expect(result).toBeNull(); }); - it("detects fold action on repo row arrow column", () => { + it("respects headerLines offset when calculating row position", () => { const groups = [createTestGroup("org/repo")]; const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 0, 1); // column 0, row 1 - expect(result).toEqual({ - row: rows[0], - column: 0, - action: "fold", + // With 4 header lines, y=5 is the first data row + // Without header offset, would be out of bounds; with offset, hits the row + const result = hitTestClick(groups, rows, 0, 1, 5, 4); + expect(result).not.toBeNull(); + expect(result?.row).toBe(rows[0]); + }); + + // ─── Fold Zone Tests (Repo Rows Only) ─────────────────────────────────────── + describe("fold zone (repo rows)", () => { + it("detects fold action at fold zone start column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, FOLD_COLUMN_START, 1); + expect(result).toEqual({ + row: rows[0], + column: FOLD_COLUMN_START, + action: "fold", + }); + }); + + it("detects fold action at fold zone end column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, FOLD_COLUMN_END, 1); + expect(result).toEqual({ + row: rows[0], + column: FOLD_COLUMN_END, + action: "fold", + }); + }); + + it("does not detect fold action outside fold zone", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, FOLD_COLUMN_END + 1, 1); + expect(result?.action).not.toBe("fold"); }); }); - it("detects select action on repo row checkbox column", () => { - const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 2, 1); // column 2, row 1 - expect(result).toEqual({ - row: rows[0], - column: 2, - action: "select", + // ─── Checkbox Zone Tests ──────────────────────────────────────────────────── + describe("checkbox zone (repo rows)", () => { + it("detects select action at checkbox zone start column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_START, 1); + expect(result).toEqual({ + row: rows[0], + column: CHECKBOX_COLUMN_START, + action: "select", + }); + }); + + it("detects select action at checkbox zone end column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_END, 1); + expect(result).toEqual({ + row: rows[0], + column: CHECKBOX_COLUMN_END, + action: "select", + }); + }); + + it("detects select action beyond checkbox column (double-click full width)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Double-click works on entire row width from CHECKBOX_COLUMN_START onwards + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_END + 1, 1); + expect(result?.action).toBe("select"); + }); + + it("detects navigate action only before checkbox zone", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Column 3 is between fold (1-2) and checkbox (4+), should be navigate + const result = hitTestClick(groups, rows, 0, 3, 1); + expect(result?.action).toBe("navigate"); }); }); - it("detects navigate action on repo row elsewhere", () => { - const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 10, 1); // column 10, row 1 - expect(result).toEqual({ - row: rows[0], - column: 10, - action: "navigate", + // ─── Navigation Zone Tests ────────────────────────────────────────────────── + describe("navigation zone", () => { + it("detects navigate action at separator column (column 3)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Column 3 is between fold (1-2) and checkbox (4+), should be navigate + const result = hitTestClick(groups, rows, 0, 3, 1); + expect(result).toEqual({ + row: rows[0], + column: 3, + action: "navigate", + }); + }); + + it("detects select action at nav column start (column 6) for repos", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Column 6 and beyond is now part of checkbox zone (full-width select) + const result = hitTestClick(groups, rows, 0, NAV_COLUMN_START, 1); + expect(result?.action).toBe("select"); + }); + + it("detects select action at far right column for repos", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Full-width double-click from checkbox start onwards + const result = hitTestClick(groups, rows, 0, 100, 1); + expect(result?.action).toBe("select"); }); }); - it("detects select action on extract row checkbox", () => { - const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 2, 1); // column 2, row 1 - expect(result).toEqual({ - row: rows[0], - column: 2, - action: "select", + // ─── Extract Row Tests ────────────────────────────────────────────────────── + describe("extract rows", () => { + it("detects select action on extract row header checkbox", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_START, 1); + expect(result).toEqual({ + row: rows[0], + column: CHECKBOX_COLUMN_START, + action: "select", + }); + }); + + it("detects select action on extract row header anywhere from checkbox column onwards", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + // Double-click on extract works on entire header line from checkbox column onwards + const result = hitTestClick(groups, rows, 0, NAV_COLUMN_START, 1); + expect(result?.action).toBe("select"); + }); + + it("detects select action on extract row header at far right column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + // Double-click works anywhere from checkbox start + const result = hitTestClick(groups, rows, 0, 100, 1); + expect(result?.action).toBe("select"); + }); + + it("detects navigate on non-header line of extract (continuation line)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + // Simulate clicking on the 2nd line of an extract that spans multiple lines + // Line 1 (header): y=1, lineOffset=0 + // Line 2 (continuation): y=2, lineOffset=1 + // This requires mocking or understanding rowTerminalLines behavior + // For now, we test the single-line case and checkbox behavior + const result = hitTestClick(groups, rows, 0, NAV_COLUMN_START, 1); + expect(result?.action).toBe("select"); // Still select on header line }); }); - it("detects navigate action on extract row elsewhere", () => { - const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; - const result = hitTestClick(groups, rows, 0, 10, 1); // column 10, row 1 - expect(result).toEqual({ - row: rows[0], - column: 10, - action: "navigate", + // ─── Section Row Tests ────────────────────────────────────────────────────── + describe("section rows", () => { + it("detects navigate action on section row (no fold/select zones)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "section", sectionLabel: "Test Section" }]; + // Section rows don't have fold or select actions, always navigate + const result = hitTestClick(groups, rows, 0, FOLD_COLUMN_START, 1); + expect(result?.action).toBe("navigate"); + }); + + it("detects navigate action on section row at any column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "section", sectionLabel: "Test Section" }]; + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_START, 1); + expect(result?.action).toBe("navigate"); }); }); - it("handles multiple rows and identifies the correct row", () => { - const groups = [createTestGroup("org/repoA"), createTestGroup("org/repoB")]; - const rows: Row[] = [ - { type: "repo", repoIndex: 0 }, - { type: "repo", repoIndex: 1 }, - ]; - // Row 1 is repoA, row 2 is repoB - const result = hitTestClick(groups, rows, 0, 2, 2); // column 2, row 2 - expect(result?.row.repoIndex).toBe(1); - expect(result?.action).toBe("select"); + // ─── Scroll Offset Tests ──────────────────────────────────────────────────── + describe("scroll offset handling", () => { + it("skips rows before scroll offset", () => { + const groups = [createTestGroup("org/repo1"), createTestGroup("org/repo2")]; + const rows: Row[] = [ + { type: "repo", repoIndex: 0 }, + { type: "repo", repoIndex: 1 }, + ]; + // With scrollOffset=1, only repo2 is visible; clicking on first line hits repo2 + const result = hitTestClick(groups, rows, 1, 1, 1); + expect(result?.row).toBe(rows[1]); + }); + + it("returns null when click is before first visible row", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // With scrollOffset=1, there are no visible rows; click returns null + const result = hitTestClick(groups, rows, 1, 1, 1); + expect(result).toBeNull(); + }); }); - it("handles section rows (no special action)", () => { - const groups = [createTestGroup("org/repo")]; - const rows: Row[] = [ - { type: "section", repoIndex: -1, sectionLabel: "section-a" }, - { type: "repo", repoIndex: 0 }, - ]; - const result = hitTestClick(groups, rows, 0, 0, 1); // section row - expect(result?.row.type).toBe("section"); - expect(result?.action).toBe("navigate"); + // ─── Column Zone Boundary Tests ───────────────────────────────────────────── + describe("column zone boundaries", () => { + it("column 3 is separator, not in any zone (repo row)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + // Column 3 is between fold (1-2) and checkbox (4-5), should be navigate + const result = hitTestClick(groups, rows, 0, 3, 1); + expect(result?.action).toBe("navigate"); + }); + + it("checkbox zone takes precedence over nav on extract", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + // Checkbox on extract header should be select, not navigate + const result = hitTestClick(groups, rows, 0, CHECKBOX_COLUMN_START, 1); + expect(result?.action).toBe("select"); + }); }); }); diff --git a/src/render/mouse-hit.ts b/src/render/mouse-hit.ts index 97c6579..d07ac64 100644 --- a/src/render/mouse-hit.ts +++ b/src/render/mouse-hit.ts @@ -3,6 +3,7 @@ import type { RepoGroup, Row } from "../types.ts"; import { rowTerminalLines } from "./rows.ts"; +import { isClickInFoldZone, isClickInCheckboxZone } from "./layout-constants.ts"; export interface ClickTarget { row: Row; @@ -14,12 +15,13 @@ export interface ClickTarget { * Hit-test a mouse click against the rendered rows. * * Returns a ClickTarget if the click lands on a valid row; null if out of bounds. - * Coordinates (x, y) are 1-indexed (terminal convention). + * Coordinates (x, y) are 1-indexed (terminal convention, per SGR mouse protocol). + * headerLines: number of header lines before the first row (position indicator + filter bar). * * Actions: - * - "fold" on repo rows: click lands on the ▸/▾ column (column 0) - * - "select" on any row: click lands on the ✓ checkbox column (column ~2-6 depending on line type) - * - "navigate" on any row: click elsewhere (just move cursor to that row) + * - "fold": click lands on the ▸/▾ emoji (repo rows only) + * - "select": click lands on the ✓ checkbox (repo or extract rows) + * - "navigate": click elsewhere (move cursor to that row) */ export function hitTestClick( groups: RepoGroup[], @@ -27,38 +29,47 @@ export function hitTestClick( scrollOffset: number, x: number, y: number, + headerLines: number = 0, ): ClickTarget | null { - // Convert 1-indexed terminal coordinates to 0-indexed row list - const clickedRowIndex = y - 1; - if (clickedRowIndex < 0 || clickedRowIndex >= rows.length) return null; + // Convert 1-indexed terminal coordinates to 0-indexed offset within visible rows, + // accounting for header lines (filter bar, position indicator) + const clickedLineOffset = y - 1 - headerLines; + if (clickedLineOffset < 0) return null; // Calculate cumulative line heights to find which row was clicked + // Iterate only through visible rows (starting from scrollOffset) let lineOffset = 0; - for (let i = 0; i < rows.length; i++) { + for (let i = scrollOffset; i < rows.length; i++) { const row = rows[i]; const group = groups[row.repoIndex] ?? undefined; - const h = rowTerminalLines(group, row); - if (lineOffset <= clickedRowIndex && clickedRowIndex < lineOffset + h) { - // This row was clicked - // Column 0 is the fold arrow (for repo rows) - // Column ~2 is the checkbox (after "▸ " or " ") - // For extract rows, the checkbox offset is slightly different based on line type + // Calculate row height, mirroring renderGroups logic for sections: + // - First row (lineOffset === 0 for sections): 1 line (label only, no blank separator) + // - Subsequent section rows: 2 lines (blank separator + label) + // - Repos and extracts: use rowTerminalLines + let h: number; + if (row.type === "section") { + h = lineOffset === 0 ? 1 : 2; + } else { + h = rowTerminalLines(group, row); + } + if (lineOffset <= clickedLineOffset && clickedLineOffset < lineOffset + h) { + // This row was clicked + // Determine action based on column position and row type let action: "fold" | "select" | "navigate" = "navigate"; if (row.type === "repo") { // Repo row layout: "▸ ✓ repo-name" - // Arrow at column 0, checkbox at column 2 (after "▸ ") - if (x === 0) { + if (isClickInFoldZone(x)) { action = "fold"; - } else if (x === 2) { + } else if (isClickInCheckboxZone(x)) { action = "select"; } } else if (row.type === "extract") { - // Extract row layout: " ✓ path:line:col" - // Checkbox at column 2 (after " ") - if (x === 2) { + // Extract row layout: " ✓ path:line:col" (top line) or fragments + // On the extract header line, double-click from checkbox onwards is select + if (clickedLineOffset === lineOffset && isClickInCheckboxZone(x)) { action = "select"; } } diff --git a/src/scroll-cooldown.test.ts b/src/scroll-cooldown.test.ts new file mode 100644 index 0000000..d1d9d7f --- /dev/null +++ b/src/scroll-cooldown.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "bun:test"; +import { + createScrollCooldownState, + recordScroll, + isScrollCooldownActive, + updateScrollCooldown, +} from "./scroll-cooldown"; + +describe("scroll-cooldown", () => { + it("should create initial state with isActive false", () => { + const state = createScrollCooldownState(); + expect(state.isActive).toBe(false); + expect(state.lastScrollTime).toBe(0); + }); + + it("should mark cooldown active after recording a scroll", () => { + const state = createScrollCooldownState(); + const now = 1000; + const updated = recordScroll(state, now); + expect(updated.isActive).toBe(true); + expect(updated.lastScrollTime).toBe(now); + }); + + it("should report cooldown active immediately after scroll", () => { + const state = createScrollCooldownState(); + const now = 1000; + const updated = recordScroll(state, now); + expect(isScrollCooldownActive(updated, now)).toBe(true); + }); + + it("should report cooldown active within cooldown window", () => { + const state = createScrollCooldownState(); + const now = 1000; + const updated = recordScroll(state); + updated.lastScrollTime = now; + // 100ms later, should still be active (cooldown is 600ms) + expect(isScrollCooldownActive(updated, now + 100)).toBe(true); + }); + + it("should report cooldown inactive after timeout", () => { + const state = createScrollCooldownState(); + const now = 1000; + const updated = recordScroll(state); + updated.lastScrollTime = now; + // 700ms later, should be inactive (cooldown is 600ms) + expect(isScrollCooldownActive(updated, now + 700)).toBe(false); + }); + + it("should handle multiple scrolls by extending the cooldown window", () => { + let state = createScrollCooldownState(); + const t0 = 1000; + const t1 = t0 + 100; // First scroll at 1000 + const t2 = t0 + 200; // Second scroll at 1100 + + state = recordScroll(state); + state.lastScrollTime = t1; + expect(isScrollCooldownActive(state, t2)).toBe(true); + + // Record another scroll at t2 + state = recordScroll(state); + state.lastScrollTime = t2; + + // At t2 + 500 (still within 600ms of t2), should be active + expect(isScrollCooldownActive(state, t2 + 500)).toBe(true); + + // At t2 + 700 (past 600ms of t2), should be inactive + expect(isScrollCooldownActive(state, t2 + 700)).toBe(false); + }); + + it("should return isActive=false after updateScrollCooldown when expired", () => { + let state = createScrollCooldownState(); + const now = 1000; + state = recordScroll(state); + state.lastScrollTime = now; + + // At now + 700ms (past 600ms cooldown), update should clear isActive + const updated = updateScrollCooldown(state, now + 700); + expect(updated.isActive).toBe(false); + }); + + it("should preserve isActive=true if cooldown still active", () => { + let state = createScrollCooldownState(); + const now = 1000; + state = recordScroll(state); + state.lastScrollTime = now; + + // At now + 100ms (within 600ms), update should keep isActive + const updated = updateScrollCooldown(state, now + 100); + expect(updated.isActive).toBe(true); + }); +}); diff --git a/src/scroll-cooldown.ts b/src/scroll-cooldown.ts new file mode 100644 index 0000000..1b72f97 --- /dev/null +++ b/src/scroll-cooldown.ts @@ -0,0 +1,51 @@ +/** + * Scroll cooldown logic to prevent accidental selection during/after trackpad momentum scrolling. + * Trackpads can send scroll events for 400-1000ms after the physical gesture ends (momentum). + */ + +export interface ScrollCooldownState { + lastScrollTime: number; + isActive: boolean; +} + +const SCROLL_COOLDOWN_MS = 600; // milliseconds — conservative for trackpad momentum + +export function createScrollCooldownState(): ScrollCooldownState { + return { + lastScrollTime: 0, + isActive: false, + }; +} + +export function recordScroll( + state: ScrollCooldownState, + now: number = Date.now(), +): ScrollCooldownState { + return { + ...state, + lastScrollTime: now, + isActive: true, + }; +} + +export function isScrollCooldownActive( + state: ScrollCooldownState, + now: number = Date.now(), +): boolean { + if (!state.isActive) return false; + const elapsed = now - state.lastScrollTime; + return elapsed < SCROLL_COOLDOWN_MS; +} + +export function updateScrollCooldown( + state: ScrollCooldownState, + now: number = Date.now(), +): ScrollCooldownState { + if (!isScrollCooldownActive(state, now)) { + return { + ...state, + isActive: false, + }; + } + return state; +} diff --git a/src/tui.ts b/src/tui.ts index 9260513..850fc3a 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -20,6 +20,18 @@ import { rebuildTeamSections, } from "./group.ts"; import { parseMouseEvent } from "./render/mouse.ts"; +import { + createScrollCooldownState, + recordScroll, + isScrollCooldownActive, + updateScrollCooldown, +} from "./scroll-cooldown.ts"; +import { + getHeaderLines, + MOUSE_BUTTON_WHEEL_UP, + MOUSE_BUTTON_WHEEL_DOWN, + MOUSE_SCROLL_STEP, +} from "./render.ts"; import { hitTestClick } from "./render/mouse-hit.ts"; import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; @@ -41,6 +53,12 @@ const KEY_CTRL_A = "\x01"; const KEY_CTRL_E = "\x05"; const KEY_CTRL_W = "\x17"; const KEY_ALT_BACKSPACE = "\x1b\x7f"; + +// ─── Terminal mouse reporting (SGR protocol) ────────────────────────────────── +// https://en.wikipedia.org/wiki/X11_mouse_protocol#SGR_1006_Protocol +// Enable/disable both basic mouse support (?1000) and SGR encoding (?1006) +const ANSI_ENABLE_MOUSE_REPORTING = "\x1b[?1000h\x1b[?1006h"; +const ANSI_DISABLE_MOUSE_REPORTING = "\x1b[?1000l\x1b[?1006l"; const KEY_CTRL_ARROW_LEFT = "\x1b[1;5D"; const KEY_CTRL_ARROW_RIGHT = "\x1b[1;5C"; const KEY_ALT_ARROW_LEFT = "\x1b[1;3D"; // Alt/Option+← (xterm, iTerm2 with Use Option as Meta key) @@ -108,6 +126,11 @@ function openInBrowser(url: string): void { // ─── Interactive TUI ───────────────────────────────────────────────────────── +/** Compute a unique key for a row to detect double-clicks. */ +function getRowKey(row: Row): string { + return `${row.type}:${row.repoIndex}:${row.extractIndex ?? -1}`; +} + export async function runInteractive( groups: RepoGroup[], query: string, @@ -130,7 +153,7 @@ 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"); + process.stdout.write(ANSI_ENABLE_MOUSE_REPORTING); let cursor = 0; let scrollOffset = 0; @@ -214,7 +237,20 @@ export async function runInteractive( focusedIndex: number; } = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; - /** Schedule a debounced stats recompute (while typing in filter bar). */ + // Persistent rows reference — updated on every redraw so it's available in the event loop + let rows: Row[] = []; + + // Flag to signal when to exit the event loop + let shouldExit = false; + + // Mouse double-click detection state + const DOUBLE_CLICK_DELAY = 300; // milliseconds + let lastClickTime = 0; + let lastClickRowKey: string | null = null; + + // Scroll cooldown to prevent accidental selection during/after trackpad momentum scrolling + let scrollCooldownState = createScrollCooldownState(); + const scheduleStatsUpdate = () => { if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); filterLiveStats = null; // show "…" while typing fast @@ -227,7 +263,7 @@ export async function runInteractive( const redraw = () => { const activeFilter = filterMode ? filterInput : filterPath; - const rows = buildRows(groups, activeFilter, filterTarget, filterRegex); + rows = buildRows(groups, activeFilter, filterTarget, filterRegex); // Normalise scrollOffset downward so the viewport is packed to the bottom. // After a fold/unfold, filter change, or navigation near the end of the // list, the rows visible from scrollOffset onwards can be fewer than @@ -265,12 +301,16 @@ export async function runInteractive( // ─── Exit handler for cleanup ──────────────────────────────────────────── const exit = () => { - // Disable SGR mouse reporting and clear terminal - process.stdout.write("\x1b[?1000l\x1b[?1006l"); + // Disable SGR mouse reporting and clear terminal. + // This ensures the terminal returns to normal mode before process exits. + process.stdout.write(ANSI_DISABLE_MOUSE_REPORTING); process.stdout.write(ANSI_CLEAR); + if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); process.stdin.setRawMode(false); process.off("SIGWINCH", onResize); - process.exit(0); + // Set flag to break out of the event loop and allow stdout buffer to flush + // before process termination. process.exit() may terminate too early. + shouldExit = true; }; // ─── Live terminal resize handler ──────────────────────────────────────── @@ -292,28 +332,92 @@ export async function runInteractive( // Try parsing as a mouse event; if it's not a mouse event, treat as keyboard input const mouseEvent = parseMouseEvent(key); if (mouseEvent !== null) { - // Hit-test the click against visible rows - const target = hitTestClick(groups, rows, scrollOffset, mouseEvent.x, mouseEvent.y); + // Ignore mouse release events; only act on press (M) + if (mouseEvent.isRelease) { + continue; + } + + // Handle wheel scroll + if (mouseEvent.button === MOUSE_BUTTON_WHEEL_UP) { + // Wheel up — scroll up by a small step + scrollOffset = Math.max(0, scrollOffset - MOUSE_SCROLL_STEP); + scrollOffset = normalizeScrollOffset(scrollOffset, rows, groups, getViewportHeight(rows)); + scrollCooldownState = recordScroll(scrollCooldownState); + redraw(); + continue; + } else if (mouseEvent.button === MOUSE_BUTTON_WHEEL_DOWN) { + // Wheel down — scroll down by a small step + scrollOffset = Math.min(Math.max(0, rows.length - 1), scrollOffset + MOUSE_SCROLL_STEP); + scrollOffset = normalizeScrollOffset(scrollOffset, rows, groups, getViewportHeight(rows)); + scrollCooldownState = recordScroll(scrollCooldownState); + redraw(); + continue; + } + + // Check scroll cooldown BEFORE hit-testing any clicks. + // During momentum scrolling, clicks can fire at scroll end positions; ignore them completely. + const now = Date.now(); + scrollCooldownState = updateScrollCooldown(scrollCooldownState, now); + if (isScrollCooldownActive(scrollCooldownState, now)) { + // Completely ignore all clicks during scroll cooldown + continue; + } + + // Calculate header lines before the first row to adjust click coordinates. + // Includes: title + summary + hints + blank (base 4) + filter bar if active. + const headerLines = getHeaderLines( + filterMode, + filterPath || filterTarget !== "path" || filterRegex, + ); + + // Hit-test the click against visible rows (non-wheel buttons) + const target = hitTestClick( + groups, + rows, + scrollOffset, + mouseEvent.x, + mouseEvent.y, + headerLines, + ); if (target !== null) { const row = target.row; - if (target.action === "fold" && row.type === "repo") { - // Toggle fold for this repo - const group = groups[row.repoIndex]; - group.folded = !group.folded; - redraw(); - } else if (target.action === "select") { - if (row.type === "repo") { - // Toggle repo selection + const rowKey = getRowKey(row); + + const isDoubleClick = + lastClickRowKey === rowKey && now - lastClickTime < DOUBLE_CLICK_DELAY; + + // Update last click state for next click detection + lastClickTime = now; + lastClickRowKey = rowKey; + + // Mouse click semantics (see docs/usage/interactive-mode.md § Mouse support): + // - Single-click: always navigate (move cursor) to the clicked row. + // - Double-click: apply the zone-specific action (fold or select). + // This is a UX feature that complements keyboard shortcuts (arrow keys for + // navigation, spacebar for selection, arrow keys for fold/unfold). + if (isDoubleClick) { + // Double-click: apply the action (fold or select) + if (target.action === "fold" && row.type === "repo") { const group = groups[row.repoIndex]; - group.repoSelected = !group.repoSelected; - } else if (row.type === "extract" && row.extractIndex !== undefined) { - // Toggle extract selection - const group = groups[row.repoIndex]; - group.extractSelected[row.extractIndex] = !group.extractSelected[row.extractIndex]; + group.folded = !group.folded; + redraw(); + } else if (target.action === "select") { + if (row.type === "repo") { + const group = groups[row.repoIndex]; + // Toggle repo selection and cascade to all extracts (same as spacebar) + group.repoSelected = !group.repoSelected; + group.extractSelected = group.extractSelected.map(() => group.repoSelected); + } else if (row.type === "extract" && row.extractIndex !== undefined) { + const group = groups[row.repoIndex]; + group.extractSelected[row.extractIndex] = !group.extractSelected[row.extractIndex]; + // Update repo selection to match if any extract is selected + group.repoSelected = group.extractSelected.some(Boolean); + } + redraw(); } - redraw(); - } else if (target.action === "navigate") { - // Move cursor to this row + // If action is "navigate", double-click does nothing (navigate already happened on single click) + } else { + // Single-click: always navigate to this row const rowIndex = rows.findIndex((r) => r === row); if (rowIndex >= 0) { cursor = rowIndex; @@ -535,7 +639,7 @@ export async function runInteractive( } // ── Normal mode ─────────────────────────────────────────────────────────────── - const rows = buildRows(groups, filterPath, filterTarget, filterRegex); + rows = buildRows(groups, filterPath, filterTarget, filterRegex); const row = rows[cursor]; if (key === KEY_CTRL_C || key === "q") { @@ -549,9 +653,13 @@ export async function runInteractive( redraw(); continue; } + // Disable SGR mouse reporting and cleanup before printing output. + // This must happen BEFORE console.log to ensure the terminal is in normal mode. + process.stdout.write(ANSI_DISABLE_MOUSE_REPORTING); process.stdout.write(ANSI_CLEAR); process.stdin.setRawMode(false); process.off("SIGWINCH", onResize); + if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); console.log( buildOutput(groups, query, org, excludedRepos, excludedExtractRefs, format, outputType, { includeArchived, @@ -561,7 +669,9 @@ export async function runInteractive( pickTeams: Object.keys(confirmedPicks).length > 0 ? confirmedPicks : undefined, }), ); - process.exit(0); + // Break out of the event loop to allow stdout buffer to flush before exit + shouldExit = true; + break; } // `h` / `?` / Esc — toggle help overlay (Esc closes only, h/? toggle) @@ -802,5 +912,10 @@ export async function runInteractive( } redraw(); + + // Break out of the event loop if exit was requested + if (shouldExit) { + break; + } } }