[GH-ISSUE #652] Add onEncounter and onDeletion hooks for virtual extmarks #177

Open
opened 2026-03-02 23:45:05 +03:00 by kerem · 0 comments
Owner

Originally created by @jorgeraad on GitHub (Feb 9, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/652

Summary

Virtual extmarks currently hard-code cursor skipping and whole-range deletion. Consumers have no way to customize what happens when the cursor hits a virtual extmark or when the user tries to delete one. This adds per-extmark onEncounter and onDeletion callbacks to ExtmarkOptions, enabling patterns like "select then act" — where the first arrow press stops at the extmark and a second press skips past it.

Motivation: opencode#8501 — pasted text summarized into virtual extmarks needs a way to expand inline. Without hooks, consumers must intercept keyboard events before the controller, duplicate private adjacency-detection logic, and fight with the wrapped setCursorByOffset. Hooks at the controller level are the clean solution.

API

interface ExtmarkEncounter {
  extmark: Readonly<Extmark>
  direction: "left" | "right" | "up" | "down" | "set"
  skip(): void           // default behavior — jump past
  setCursor(offset: number): void  // place cursor at specific offset
}

interface ExtmarkDeletionEncounter {
  extmark: Readonly<Extmark>
  direction: "backward" | "forward"
  deleteExtmark(): void  // default behavior — delete entire range
  prevent(): void        // block deletion
}

// New optional fields on ExtmarkOptions:
onEncounter?: (encounter: ExtmarkEncounter) => void
onDeletion?: (encounter: ExtmarkDeletionEncounter) => void

Behavior

  • Only fires for virtual extmarks (non-virtual don't trigger cursor skipping)
  • Fully backwards compatible — omitting callbacks preserves current behavior
  • Callback must call exactly one action method; if none called, default behavior runs as fallback
  • Callbacks stored in separate maps (not on Extmark interface), following the existing metadata pattern — excluded from undo/redo snapshots

Scope

In scope:

  • onEncounter hooks wired into all 5 wrapped cursor methods (moveCursorLeft, moveCursorRight, moveUpVisual, moveDownVisual, setCursorByOffset)
  • onDeletion hooks wired into deleteCharBackward and deleteChar
  • Callback lifecycle management in create(), delete(), and clear()
  • Tests for both hooks
  • Updated extmarks demo with "select then act" interaction

Out of scope:

  • Mutable extmark properties (e.g., changing styleId in place)
  • Controller-level hooks (vs per-extmark)
  • Insertion hooks
  • Consumer-side changes (opencode)
Originally created by @jorgeraad on GitHub (Feb 9, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/652 ## Summary Virtual extmarks currently hard-code cursor skipping and whole-range deletion. Consumers have no way to customize what happens when the cursor hits a virtual extmark or when the user tries to delete one. This adds per-extmark `onEncounter` and `onDeletion` callbacks to `ExtmarkOptions`, enabling patterns like "select then act" — where the first arrow press stops at the extmark and a second press skips past it. **Motivation:** [opencode#8501](https://github.com/anomalyco/opencode/issues/8501) — pasted text summarized into virtual extmarks needs a way to expand inline. Without hooks, consumers must intercept keyboard events before the controller, duplicate private adjacency-detection logic, and fight with the wrapped `setCursorByOffset`. Hooks at the controller level are the clean solution. ## API ```typescript interface ExtmarkEncounter { extmark: Readonly<Extmark> direction: "left" | "right" | "up" | "down" | "set" skip(): void // default behavior — jump past setCursor(offset: number): void // place cursor at specific offset } interface ExtmarkDeletionEncounter { extmark: Readonly<Extmark> direction: "backward" | "forward" deleteExtmark(): void // default behavior — delete entire range prevent(): void // block deletion } // New optional fields on ExtmarkOptions: onEncounter?: (encounter: ExtmarkEncounter) => void onDeletion?: (encounter: ExtmarkDeletionEncounter) => void ``` ## Behavior - Only fires for **virtual** extmarks (non-virtual don't trigger cursor skipping) - Fully backwards compatible — omitting callbacks preserves current behavior - Callback must call exactly one action method; if none called, default behavior runs as fallback - Callbacks stored in separate maps (not on `Extmark` interface), following the existing `metadata` pattern — excluded from undo/redo snapshots ## Scope **In scope:** - `onEncounter` hooks wired into all 5 wrapped cursor methods (`moveCursorLeft`, `moveCursorRight`, `moveUpVisual`, `moveDownVisual`, `setCursorByOffset`) - `onDeletion` hooks wired into `deleteCharBackward` and `deleteChar` - Callback lifecycle management in `create()`, `delete()`, and `clear()` - Tests for both hooks - Updated extmarks demo with "select then act" interaction **Out of scope:** - Mutable extmark properties (e.g., changing `styleId` in place) - Controller-level hooks (vs per-extmark) - Insertion hooks - Consumer-side changes (opencode)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#177
No description provided.