Skip to content

feat(core): wire MouseLevel through setMouseMode + CliRendererConfig#1039

Open
namastex888 wants to merge 1 commit into
anomalyco:mainfrom
namastex888:feat/mouse-level-config-surface
Open

feat(core): wire MouseLevel through setMouseMode + CliRendererConfig#1039
namastex888 wants to merge 1 commit into
anomalyco:mainfrom
namastex888:feat/mouse-level-config-surface

Conversation

@namastex888

Copy link
Copy Markdown

Closes #1038.

What

Wires the existing-but-dormant MouseLevel enum (packages/core/src/zig/terminal.zig:33-39) through setMouseMode and surfaces it on CliRendererConfig as mouseLevel?: MouseLevel | "none" | "basic" | "drag" | "motion".

useMouse + enableMouseMovement keep working as a back-compat shim so every existing consumer continues to produce identical output bytes.

Why

MouseLevel.basic ("click only") is declared in source but unreachable today — setMouseMode always emits ?1000h ?1002h ?1006h whenever mouse is enabled. There's no way to subscribe to clicks-only and let the host terminal own drag events natively.

The motivating use case is downstream in automagik-dev/genie: when the TUI runs over SSH from terminals that don't implement OSC 52 (notably Warp on macOS), the active ?1002h subscription captures drag events that the user wanted to use for native text selection. A genie-side override (\e[?1002l after createCliRenderer()) ships in v5 day-one as a workaround. The long-term plan is to drop that override once mouseLevel: "basic" lands here.

Full discussion in #1038.

Changes

Zig

  • terminal.zigsetMouseMode now takes (level: MouseLevel). Emits per level:
    • .none → disable all four (?1000l ?1002l ?1003l ?1006l)
    • .basic?1002l (downgrade) + ?1000h + ?1006h
    • .drag?1003l (downgrade) + ?1000h + ?1002h + ?1006h (matches the prior (true, false) output)
    • .motion?1000h + ?1002h + ?1003h + ?1006h (matches the prior (true, true) output)
    • .pixels → currently treated as .drag; reserved for future pixel-coordinate decoding
    • New state.mouse_level field records the canonical level. Existing state.mouse and state.mouse_movement remain a derived projection so restoreTerminalModes, resetState, and any external callers reading those fields keep working.
    • Same-level calls early-return (no-op).
  • setMouseModeLegacy(enable, movement) — new shim mapping the prior two-bool contract onto a MouseLevel.
  • renderer.zigenableMouse(bool) routes through the legacy shim (zero behavior change). New setMouseLevel(level) exposes the canonical API. disableMouse calls setMouseMode(.none) directly.
  • lib.zig — new setMouseLevel(rendererPtr, u8) FFI export.

TypeScript

  • zig.ts — matching FFI binding + RenderLib method.
  • renderer.tsMouseLevel enum exported (numeric values match the Zig enum order). mouseLevel added to CliRendererConfig with a resolveMouseLevel() helper centralizing the legacy back-compat shim. CliRenderer gains a public mouseLevel getter/setter that drives the new FFI; the private enableMouse() falls through to the level-aware path only when the resolved level isn't .drag or .motion, so legacy callers stay on the original code path bit-for-bit.

Tests (Zig)

  • Replaced the two existing setMouseMode(true, ...) tests with level-based equivalents; both also assert state.mouse_level.
  • Added coverage for .basic, .none (drives through .drag first), and same-level no-op.
  • Added setMouseModeLegacy round-trip tests for all three legacy combinations to guarantee behavior preservation.

Back-compat / API surface

Old call Maps to Bytes emitted
setMouseMode(true, false) removed, see below was ?1000h+?1002h+?1006h
setMouseMode(true, true) removed, see below was +?1003h
setMouseMode(false, _) removed, see below was disable-all
setMouseModeLegacy(true, false) setMouseMode(.drag) identical
setMouseModeLegacy(true, true) setMouseMode(.motion) identical
setMouseModeLegacy(false, _) setMouseMode(.none) identical
new setMouseMode(.basic) ?1000h + ?1006h

The TypeScript surface is fully back-compat: existing useMouse / enableMouseMovement callers see no behavior change.

Validation

  • bun test (TS) — N/A in this draft until reviewer confirms preferred test wiring; happy to add renderer.mouse.test.ts covering resolveMouseLevel() if useful.
  • Zig tests added in tests/terminal_test.zig cover all five level transitions plus the legacy shim.

Open questions for reviewers

  1. Naming: setMouseMode(level) reuses the old name; alternative is to keep old setMouseMode(enable, movement) and add a fresh setMouseLevel(level). I went with the former because the wish from the downstream user explicitly asked for a signature change, but I'm happy to flip if you prefer additive-only.
  2. .pixels: reserved but treated as .drag for now (matches the existing TODO at line 572). Should I add a ?1016h emit path now or leave it for a follow-up?
  3. useMouse deprecation: I left both useMouse and enableMouseMovement as ?: boolean in CliRendererConfig with a comment pointing at mouseLevel. Want a JSDoc @deprecated annotation, or keep them first-class?

Refs

Marking draft pending #1038 discussion.

Replace `setMouseMode(enable: bool, enable_movement: bool)` with
`setMouseMode(level: MouseLevel)` so consumers can subscribe to a precise
slice of the xterm mouse protocol instead of being locked into the
historical `clicks + drag + motion` default.

The dormant `MouseLevel` enum in terminal.zig (none / basic / drag /
motion / pixels) is now actually consumed:

- terminal.zig: `setMouseMode` takes a MouseLevel and emits the matching
  DECSET sequences. `state.mouse_level` records the canonical level;
  `state.mouse` and `state.mouse_movement` remain a back-compat
  projection so `restoreTerminalModes`, `resetState`, and any external
  callers reading those fields keep working unchanged. A new
  `setMouseModeLegacy(enable, movement)` shim preserves the prior
  two-bool contract for callers that haven't migrated.
- renderer.zig: `enableMouse(bool)` now routes through the legacy shim;
  new `setMouseLevel(level)` exposes the canonical API. `disableMouse`
  calls `setMouseMode(.none)` directly.
- lib.zig: new `setMouseLevel(rendererPtr, u8)` FFI export. The u8
  matches MouseLevel ordinals; out-of-range values fall back to .none.
- zig.ts: matching FFI binding + RenderLib method.
- renderer.ts: `MouseLevel` enum exported; `mouseLevel?: MouseLevel |
  \"none\" | \"basic\" | \"drag\" | \"motion\"` added to
  `CliRendererConfig`; `resolveMouseLevel()` helper centralizes the
  legacy back-compat shim (false => None; true,false => Drag;
  true,true => Motion). CliRenderer gets a public `mouseLevel`
  getter/setter that drives the new FFI.

Tests:
- Replace the two existing `setMouseMode(true, ...)` tests with
  level-based equivalents that also assert `state.mouse_level`.
- Add `.basic`, `.none`, and same-level-no-op coverage.
- Add `setMouseModeLegacy` round-trip tests for all three legacy
  combinations to guarantee the shim preserves prior behavior.

Use case: callers that want native terminal drag-to-select (e.g.
clipboard-aware terminals like Warp on macOS) can set
\`mouseLevel: \"basic\"\` to leave drag events with the host terminal
while still receiving click events for navigation. Today this requires a
manual ?1002l write after createCliRenderer() returns.

Refs: https://github.com/automagik-dev/genie wish tui-native-selection
namastex888 added a commit to automagik-dev/genie that referenced this pull request May 9, 2026
Group 1 / Jaw A — Local mouse override (drag terminal-owned):
- bump @opentui/{core,keymap,react} 0.2.0 → 0.2.6
- src/tui/render.tsx: disableDragTracking() helper emits \e[?1002l
  after createCliRenderer() and re-emits on lifecycle events
  (suspend/resume + useMouse setter) so OpenTUI's hardcoded ?1002h
  drag-tracking subscription is overridden, restoring native
  terminal selection in the OpenTUI Nav pane
- src/tui/render.test.ts: +73 LOC test suite covering override
  emission + suspend/resume re-emit
- src/tui/components/Nav.tsx: audit comment confirming no
  onMouseDrag/onMouseDragEnd registrations (drag intentionally
  terminal-owned in v5)

Group 2 / Jaw B — Strip OSC 52 plumbing:
- scripts/tmux/genie.tmux.conf: set-clipboard external → off,
  drop terminal-overrides Ms= cap, replace copy-pipe-and-cancel
  ~/.genie/scripts/osc52-copy.sh with copy-selection-and-cancel
- scripts/tmux/tui-tmux.conf: same three edits
- src/__tests__/tmux-config.test.ts: invariants inverted (assert
  set-clipboard off, no Ms=, copy-selection-and-cancel)
- CHANGELOG.md: v5-launch entry naming the user-visible contract
  (drag highlights, Cmd+C copies, no auto OSC 52 emit, GENIE_TUI_MOUSE=0
  is the env-var escape hatch)
- .docs-vendor pointer bump: includes 5a1f1715 docs(genie): TUI
  clipboard semantics in v5 — terminal-native selection
- scripts/tmux/osc52-copy.sh kept on disk per D7 for ad-hoc operator
  use; just no longer invoked from tmux config

Group 3 / Jaw C — Upstream PR (non-blocking):
- Issue: anomalyco/opentui#1038 "Surface MouseLevel through
  setMouseMode for click-only mouse use cases"
- Draft PR: anomalyco/opentui#1039 "feat(core): wire MouseLevel
  through setMouseMode + CliRendererConfig" (commit 0594866d,
  +297/-34 across 6 files: terminal.zig, renderer.zig, lib.zig,
  zig.ts, renderer.ts, terminal_test.zig)
- Once accepted and 0.2.7+ ships, follow-up minor in genie will
  set mouseLevel: 'basic' natively and delete the Jaw A local
  override

Group 4 (QA smoke gate on Warp + Terminal.app on macOS) is
manual and pending operator hardware.

Wish: .genie/wishes/tui-native-selection/WISH.md
Sister: v5-major-cutover-handoff (same v4-final / v5-launch boundary)

Co-Authored-By: Claude <noreply@anthropic.com>
@namastex888 namastex888 marked this pull request as ready for review May 9, 2026 21:57
namastex888 added a commit to automagik-dev/genie that referenced this pull request May 9, 2026
Reframe Jaw A from "transitional workaround" to permanent v5
solution. Stop pretending Jaw C (upstream PR anomalyco/opentui#1039)
is on the v5 critical path — it's now goodwill only, not blocking
v5, and genie does NOT adopt it even if it merges.

Rationale (Felipe pushback 2026-05-09): relying on anomalyco's
merge cadence is not acceptable. Pinning a fork via git URL would
cost more in supply-chain risk (Zig toolchain on install hosts, no
cosign provenance, fork-rebase maintenance burden) than the 30 LOC
override itself. The override is small, well-commented, fully
reversible, and depends on nothing outside the genie repo + the
npm-published @opentui/core@0.2.6.

Changes:
- D3: "ships immediately, regardless of upstream PR status"
      → "**permanent** v5 solution, not transitional"
- D5: "Jaw C is part of the wish but not blocking" + "use it
      natively when upstream lands"
      → "**goodwill only**, not on v5 critical path. Genie ships
      v5 with the local override regardless of #1039's status.
      Even if anomalyco merges, we do NOT restructure genie's
      code to depend on it."
- D6: amended to clarify it's a courtesy decision for the
      upstream PR shape, not a genie-affecting choice
- IN scope (Jaw C): "non-blocking" + "follow-up minor adopts it"
      → "goodwill only, not v5-blocking. Genie does not adopt
      this in v5 even if it merges."
- Success criterion: "PR opened" → "PR opened + flipped to
      ready-for-review" with goodwill-only annotation

Side action: anomalyco/opentui#1039 flipped from draft to
ready-for-review.

Wish: .genie/wishes/tui-native-selection/WISH.md

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface MouseLevel through setMouseMode for click-only mouse use cases

1 participant