Commits
IntelliJ Platform Gradle Plugin's local cache (downloaded IDE artifacts,
update locks, javaagent locks). Regenerated on every Gradle build, no
reason for them to be in the tree.
Adds .intellijPlatform/ to .gitignore and removes the three files that
had slipped in (a self-update.lock, coroutines-javaagent.lock, and a
bundled-module XML descriptor).
Two workflow files under .tangled/workflows/, both running under nixery
with jdk21 / rustc / cargo / git from nixpkgs.
ci.yml fires on every push: builds the Rust oracle binary, runs the
engine test suite (which includes the differential-vs-Helix tests via
OracleClient), then builds the plugin distribution zip. Catches broken
commits before they get tagged.
release.yml fires on tag push: same steps, then attaches the resulting
Helicase-*.zip to the tag via spindle-artifact (third-party tool at
git+https://tangled.org/regnault.dev/spindle-artifact). Under the hood
spindle-artifact authenticates with ATProto via app password, uploads
the zip as a blob, and creates a record pinning the blob to the named
tag — Tangled's actual mechanism for per-tag attachments.
The publishing account's handle (isaaccorbrey.com) is hardcoded in
release.yml's `environment` block; the secret half (ATPROTO_APP_PASSWORD)
lives in repo secrets and Spindle injects it at run time.
One known fragility: `git tag | tac | head -1` fishes "the latest tag"
out of the runner rather than reading the trigger context directly.
That works while CI is fired by the tag actually being latest. If
non-chronological tags ever appear, the upload step picks the wrong
one; worth swapping to a Spindle-exposed variable when one becomes
available.
README at repo root covering features, sideload install (incl. Settings →
Helicase first-run tip), product compatibility table (all JetBrains
IntelliJ-Platform IDEs), and dual-license pointer. Compatibility table
verified for DataGrip; rest marked "needs verification" — plugin only
depends on `com.intellij.modules.platform` so loading should be uniform.
Dual MIT + Apache 2.0 license at repo root (LICENSE-MIT, LICENSE-APACHE)
following the Rust-ecosystem convention. README's "licensed under MIT or
Apache 2.0, at your option" links point at both files.
CHANGELOG.md in Keep-a-Changelog format with custom section names that
read more naturally for an IDE-plugin audience: Release highlights,
Breaking changes, Deprecations, New features, Fixed bugs. Initial
[Unreleased] entry summarizes the 0.1.0 surface — Helix config ingest,
chord intake, dynamic minor modes, gw jump labels, LSP gotos, : command
mode, persistent settings, registers, logger, engine command surface,
and the differential-testing harness.
Plugin build pipeline (plugin/build.gradle.kts):
- `org.jetbrains.changelog` plugin reads CHANGELOG.md and renders the
current version's block (or [Unreleased] fallback) as HTML into the
plugin descriptor's <change-notes>, so the IDE plugin-info dialog
shows the same release notes the repo does.
- Single-source `version = "0.1.0"` at the top drives plugin descriptor
version, changelog plugin version lookup, and distribution zip name.
- `buildPlugin` task's archive base renamed from "plugin" to "Helicase"
so the sideload zip is `Helicase-0.1.0.zip` (matches the README's
install instructions).
- `repositoryUrl = "https://tangled.org/isaaccorbrey.com/helicase"` for
the changelog plugin's auto-linking of references.
Hoist the enabled flag from per-editor HelicaseState to app-wide
HelicaseSettings. Opening a new editor used to leave Helicase off
until you re-toggled — wrong: modal editing is conceptually one
state that spans the IDE. Now every editor inherits the live value;
toggling via Ctrl-Shift-H flips it everywhere.
Persistence: HelicasePersistentSettings (PersistentStateComponent
on the application level) stores `enabledByDefault` to
`helicase.xml` in IntelliJ's config dir. Survives restarts.
HelicaseConfigurable registers a Settings → Helicase panel with
the single checkbox; apply() writes to the persistent state AND
updates the live HelicaseSettings.enabled so the change takes
effect immediately rather than waiting for the next restart.
Startup precedence: persistent default loads first, then TOML
[helicase].enabled overrides if explicitly set. Power users
managing everything via config.toml stay in control; everyone
else gets a sensible GUI knob.
Toggle action (Ctrl-Shift-H) is session-only — it doesn't
write through to persistent. "Set in Preferences" = permanent,
"press the toggle" = transient. Matches the Vim/Helix mental
model where config = permanent, runtime = session.
Also includes the InvokeIdeCommand handler dispatch in the typed
and action wrappers (file-coupled to the .enabled hoist; can't be
cleanly split).
Engine: new PendingAction.InvokeIdeCommand(name) effect — a generic
"plugin, please run the IDE command called <name>". Lets g-family LSP
commands delegate without the engine knowing about IntelliJ action IDs.
Four engine commands emit it for gd / gy / gi / gr; engine state is
otherwise unchanged.
Plugin: HelicaseCommand resolves the four names to
GotoDeclaration / GotoTypeDeclaration / GotoImplementation /
ShowUsages — daily-driver code-nav actions that already exist in
the platform and respect language semantics (so they work for SQL
identifiers in DataGrip, references in Kotlin, etc.).
Also remap two <space> bindings whose original mappings missed what
Helix actually does:
- <space>f file_picker -> GotoFile (fuzzy file finder), was
RecentFiles which is closer to a buffer-list popup.
- <space>b buffer_picker -> RecentFiles, which IS Helix's
per-buffer popup intent; was Switcher (Ctrl-Tab cycle).
(InvokeIdeCommand dispatching landed in the global-settings commit on
top because the typed/action handler edits are intertwined with the
enabled-flag hoist; engine emits correctly even at this commit, but
the side effect fires once the next commit lands.)
HelicaseInfoPopup used to surface only engine-side pending keys via
HelpRegistry (so `m`-family and `g`-family hints rendered). Now it
checks the plugin-side pending minor mode FIRST and, if active,
renders entries straight from that submap.
Plugin minor modes get the same hint UX `m`/`g` always had — for
free, including any user-defined submap. Action entries show their
binding string (`:bp`, `@<esc>vCi`); Submap entries show `→ <name>`
so it's clear that key opens another mode.
HelicaseChordDispatcher refreshes the popup on every minor-mode
state change — enter, exit, and action-fired-from-inside. Last case
was the bug behind the user-reported "popup stays after `<space>f`
opens the file picker": Action firing cleared pendingMinorMode but
never refreshed the popup, so the stale hint floated until another
key event triggered a state recompute.
Startup validator was flagging `workspace_symbol -> GotoClass` as missing
in every session. GotoClass is Java-flavored (no Java classes in
DataGrip), but Helix's `<space>S` is "workspace symbol picker" — the
project-wide variant of `<space>s`'s "file-local symbol picker".
Remap:
- `<space>s` (file-local) -> FileStructurePopup
- `<space>S` (workspace) -> GotoSymbol
Picks up Helix's symbol-picker semantics in DataGrip terms and gets rid
of the persistent missing-action warning at startup.
Engine: `gw` emits a one-shot PendingAction.GotoWord effect on the
returned state. Keeps the engine pure — the action is just a tagged
enum the plugin inspects after `engine.apply`; no IntelliJ code in the
engine module.
Plugin: JumpLabelOverlay implements Helix's `jump_to_word` algorithm —
scan the visible viewport for 2+ char words, sort by distance from
cursor (closest first), assign 2-char labels from the configured
alphabet (`alphabet[i/N] + alphabet[i%N]`, so words[0] = `jj` for the
user's `jklfdsau…` alphabet), render the labels on the IDE's glass
pane as opaque JComponent overlays.
Glass pane rather than RangeHighlighter or contentComponent child:
the editor's text-fragment paint pass overdrew labels in both other
approaches — both glyphs ghosted through. Glass pane is the topmost
layer of the IDE window so Swing paints it over everything.
After the first key matches, prune labels that don't start with that
char so only the still-in-play set stays visible. Second key locks in
the jump; Esc / non-alphabet key dismisses.
Config: `[editor].jump-label-alphabet` from TOML config feeds
HelicaseSettings. Defaults to Helix's `"abcdefghijklmnopqrstuvwxyz"`.
Loader falls back to `~/.config/helix/config.toml` when there's no
Helicase-specific config — users can point the same file at both
without duplication.
Reference impl: helix-term/src/commands.rs::jump_to_word at rev 8c41b11.
Two intentional simplifications vs upstream: distance-sort instead of
explicit alternating forward/backward walk (same result for typical
layouts), and engine emits the same effect regardless of mode (Helix
has separate `extend_to_word` for Select).
Replace the flat chord -> binding-string map with a Binding sealed type:
Action wraps a binding string and fires on press; Submap wraps a name +
inner table and represents a minor mode. The chord dispatcher tracks
the active submap in HelicaseState.pendingMinorMode — every keypress
while pending looks up against that map instead of the top-level table,
Esc cancels at any nesting depth, unbound keys silently abort, and
nested Submaps stack so submaps can themselves contain submaps.
TOML loader walks tables recursively: string values become Actions,
sub-tables become Submaps. Both `space = { f = ":..." }` inline-table
syntax and `[keys.normal."<space>"]` nested-section syntax work. User
overrides merge per-level so e.g. setting `<space>.f` doesn't blow
away the rest of the default <space> map.
Chord canonicalization now emits a Helix-style string for ANY key, not
just modifier-chords/F-keys: plain `f` -> `"f"`, space -> `"<space>"`,
shifted-? -> `"?"`, Esc/Ret/Tab -> `"<esc>"` etc. Plain chars only
trigger the dispatcher when bound at the top level (`<space>` is the
only default) or when in a pending minor mode. KEY_TYPED is suppressed
following a consumed printable KEY_PRESSED so the chord doesn't ALSO
type its character through TypedActionHandler.
Default <space> submap mirrors Helix's leader bindings via IntelliJ
action delegates: f -> RecentFiles, F -> GotoFile, b -> Switcher,
/ -> FindInPath, a -> ShowIntentionActions, k -> QuickJavaDoc,
r -> RenameElement, s -> GotoSymbol, S -> GotoClass, d -> GotoNextError,
? -> GotoAction.
HelicaseCommand.validate() probes every IDE-action-delegating command
at startup, logs warnings for any IDs not registered (catches typos,
version drift, and DataGrip-vs-IDEA-Ultimate differences before the
user reaches for a broken binding).
Sift through every command implemented so far and confirm behavior
against the real Helix oracle. Six divergences found and corrected:
- `*` (`search_selection`): used the literal selection contents,
regex-escaped. Was expanding 1-char block cursors to the surrounding
word with \\b boundaries — that's `<A-*>`
(`search_selection_detect_word_boundaries`), a separate Helix command.
- `o` / `O` (`open_below` / `open_above`): match Helix's indent-on-
newline behavior. Source line's indent in VISUAL columns is measured,
then `indentString` (default `\\t`) is laid down to match. Range
shape is a 1-char forward block with `restoreCursor = true` instead
of a point. The "copy leading whitespace verbatim" behavior I'd
written was my own invention.
- `>` / `<` (`indent`/`unindent`): default `indentString` changed from
4 spaces to `\\t` — Helix's actual default. Dedent now measures
leading whitespace in visual columns (tab → next tab stop, space →
one column) and removes one shiftwidth's worth, not one char's worth.
- `ge` (`goto_last_line`): trailing newline does NOT count as a
separate "last line". A buffer ending in `\\n` lands `ge` on the
start of the last content line, not the empty position past the
final newline. (Earlier "fix" had been my mistaken interpretation
of the user's report.)
- `r<c>` (`replace`): replaces every char in selection, INCLUDING
newlines. Comment claiming "newlines preserved (matches Helix)" was
wrong — the oracle disagrees.
Tests converted to `checkOracle`: IndentTest (10), CopySelectionOnLineTest
(7), OpenLineTest (7), GotoReplaceJoinTest (12). The QuoteMimAndSurroundAddTest
quote-mim/mam cases stay hand-asserted with a clearer comment explaining
WHY — Helix needs tree-sitter to see a string literal as a pair, and
the oracle's test syntax loader has no grammar for our plaintext buffers.
Engine compensates with a plain-text quote-parity walker the oracle can't
match. Surround-add (ms<c>) was and stays oracle-checked.
Indent/dedent tests use `<gt>` / `<lt>` for the `>` / `<` keys: bare
`>` and `<` are reserved as escape delimiters in Helix's `parse_macro`.
This audit is the value differential testing was meant to deliver —
six wrong assumptions caught and corrected without manual reproduction.
A forward block range with `head > text.length` (the phantom empty
position past the last char — produced by e.g. `o`/`O` opening a new
line) used to throw `StringIndexOutOfBoundsException` from the marker
insertion. Helix's reference annotation renders the same case as
`#[|]#` (point at end), so the engine and the oracle's annotated
output couldn't be diffed for these cases.
Patch: clamp anchor and head to [0, text.length]; if they collapse
equal after clamping, emit the point form. Preserves all in-bounds
behavior; only changes the previously-erroring out-of-bounds case
to render the same string Helix does.
Three layers that together let the user reach for the chords/commands in
their actual Helix config.toml and have them work in Helicase.
`:`-mode (HelicaseCommand + HelicaseTypedHandler): typing `:` in Normal
or Select opens a small input prompt; submission resolves through
HelicaseCommand. Built-in mappings cover the user's daily-driver bindings:
`:bp` / `:bn` (PreviousEditorTab / NextEditorTab), `:bc` / `:q` (close),
`:w` (save), `:wq`, `:moveLineUp` / `:moveLineDown` (PSI-aware via
IntelliJ's MoveLineUp/Down), `:reload` (re-reads config.toml without
restart). Action invocation uses DataManager.getDataContext on
`editor.component` — the outer Swing component carries the EDITOR_WINDOW
data the tab actions consult, which the inner `contentComponent` does
not.
Chord intake (HelicaseBinding + HelicaseChordDispatcher): captures
Ctrl-/Alt-/F-key shortcuts that TypedActionHandler and EditorActionManager
can't see. Registered through IdeEventQueue.addDispatcher (NOT the AWT
KeyboardFocusManager) so we run BEFORE IntelliJ's keymap dispatcher
fires — otherwise Ctrl-K would trigger both our binding AND IntelliJ's
default "Checkin Project" action and splice text behind our back. Each
chord maps to a Helix-style binding string: `":<cmd>"` invokes a colon-
command directly; `"@<segments>"` walks a mixed key-feed + colon-command
sequence into the engine.
TOML config (HelicaseConfig): reads `~/.config/helicase/config.toml`
(or `$XDG_CONFIG_HOME/helicase/config.toml`, or `$HELICASE_CONFIG`),
parses with tomlj, merges `[keys.normal]` / `[keys.insert]` /
`[keys.select]` tables over the built-in defaults. User can drop their
literal Helix config.toml at the Helicase path — non-keymap sections
(theme, editor settings) are silently ignored.
One logfmt line per keystroke, carrying every relevant field as
key=value pairs. Pattern is canonical-log-lines / "logging sucks":
stop scattering a handler's context across N debug lines, collect it
into one structured event you can grep.
HelicaseLog.event { ... } builds the event via a fluent EventBuilder
with field() and a state(prefix, EditorState) shortcut for before/after
dumps. Off by default; gated through IntelliJ's Logger so it goes
silent until the user enables `#io.helicase` via Help → Diagnostic
Tools → Debug Log Settings. Inline + early-return for zero overhead
when disabled.
Instrumented in HelicaseTypedHandler and HelicaseActionWrapper — both
emit `evt=key source=typed|action key=<k> keys=<buffer>
before.{mode,primary,ranges,search} after.{...} pending=<bool>
apply_us=<microseconds>`. apply_us is wall-clock of just the engine
call, useful for spotting unexpectedly slow commands.
Side benefit confirmed in dogfooding: unrouted keys (`:`, `q`, `L`
etc.) show up as no-op events with unchanged before/after state,
making it obvious what the user reached for vs. what's actually
implemented. Surfacing the gap, not just bugs.
Two pieces of state that need to live in HelicaseState because they don't
survive being rebuilt from IntelliJ's CaretModel each keystroke:
insertRanges — In Insert mode, EditorBridge renders point carets (no
selection highlight) at cursor() rather than head. Without selection
highlight, anchors would be lost on the next read; insertRanges
shadows them so Esc can restore the full Helix-style range. Also
positions the caret at cursor() instead of head: head for a forward
range is one PAST the last char, so rendering there put the caret on
the wrong line when typing at end-of-line.
primaryIndex — Helix's primary cursor doesn't reliably round-trip
through IntelliJ's CaretModel. Setting `caretsAndSelections` doesn't
let us pick a primary by index, and `allCarets` returns by offset
order on read. Without shadowing this, `*vn` once would work but `n`
again would re-search from the original (wrong) primary and dedupe
against an existing match — `n` appeared "stuck" forward. Plus reorder
the caretStates list so primary comes first (IntelliJ uses the first
entry as its visual primary), and scroll the viewport to the primary
on every write so `n`/`N` motion is visible even when no new range is
added.
Five engine additions tied together by what the user's daily-driver Helix
config reaches for.
`*` — search-cursor-word/selection. Sets EditorState.lastSearch from the
primary range: literal-escaped contents for multi-char selections; for a
1-char block on a word char, expands to the surrounding word with \b
boundaries (so block-cursor `*n` jumps word-to-word, not every-char-of-
the-buffer). Doesn't navigate; `n`/`N` handle that.
`C` / `<A-C>` — duplicate primary selection onto next/previous line at
the same columns, preserving direction. Count multiplies. Lines too
short to hit the start column are skipped (Helix's behavior).
Select-mode `n` / `N` — primary always advances by one match in
direction; if the destination is already a range, just move primary
there; otherwise add the match as a new range and become the primary.
Walking forward grows the cursor set, walking back retraces, going
further back than the start grows in that direction. User-described
example: `_ _ _ #[4]# _ _` + `nn` adds 5,6; `NN` retraces; `N` adds 3.
`<bs>` — multi-cursor backspace. Engine deletes the char before each
range's `cursor()`, maps anchor/head through the deletion set. Plus
`HelicaseStartup` routes `<ret>` and `<bs>` through the engine in
Insert mode too — IntelliJ's smart-indent on Enter would otherwise
desync the Insert-mode shadow ranges, leaving selections at stale
offsets when Esc restores them.
Tests cover all of the above plus the n/N walk-and-retrace pattern.
Daily-driver SQL editing additions:
- o / O open a new line below / above the cursor and enter Insert mode,
copying the source line's leading whitespace as the new indent. (Helix
uses tree-sitter for smarter heuristics; copy-prev-indent is the
no-language-hook default.)
- > / < indent / dedent every line touched by any selection, with count
multiplying the shift. The position-mapping rule for indent edits
diverges from the general edit-mapping: a position that lands on a
line-start where indent was inserted STAYS at that line-start, so
repeated > on the same selection keeps shifting it. Default
shiftwidth is 4 spaces; CommandContext.indentString is the override
hook for a future plugin wire-up against CodeStyleSettings.
- Ctrl-c in Normal/Select mode toggles line comment by delegating to
IntelliJ's CommentByLineComment action (so SQL gets `--` and Kotlin
gets `//` for free). HelicaseDelegateActionWrapper is the general
mechanism — falls through to the previous handler in Insert mode so
Copy still works while typing.
EditorBridge now computes a minimal-diff replaceString instead of clobbering
the whole document with setText, and applies the text edit and caret sync
inside a single WriteCommandAction. Result: one paste keystroke produces
one undo entry (was two), and incremental highlighting / PSI updates don't
re-fire on unchanged buffer regions.
HelicaseTypedHandler.performIdeAction now calls action.update(event) and
only fires actionPerformed when the presentation is enabled. Without this,
pressing u (or U) with an empty history raised an 'IDE error occurred'
notification — which matches neither Helix's behavior (no-op at boundary)
nor any sensible modal-editor expectation.
Engine: Registers store keyed by char, RegisterContent {values, linewise}.
Yank writes per-range slots into the default register " (or "<c> for named
registers); paste reads them with i%N cycling so multi-cursor paste is
symmetric to multi-cursor yank. Linewise detection (all values end with
\n AND all ranges start at line boundary) drives paste positioning —
linewise lands on the next/previous line, charwise lands at to()/from().
Count multiplies the per-range slot inline. R substitutes selection with
register contents using the same pairing rules.
Plugin: AwtClipboard bridges the engine's Clipboard interface to the
host clipboard for "+ and "*. Yank to either writes through; paste from
either falls back to a live read when the engine slot is empty.
HelicaseState carries the Registers across keystrokes alongside
lastSearch, since EditorBridge.read otherwise rebuilds EditorState from
the IDE document model fresh.
The blackhole register _ swallows writes and reads as empty.
text.lastIndexOf('\n', text.length - 2) skipped past the final newline
and returned the start of the second-to-last line. Use the unconstrained
lastIndexOf and allow moveAllRanges to accept target = text.length so
the cursor can rest on the empty trailing-line position.
Filling in commands users hit every minute. All easy now that the
keymap is data-driven — each addition is a Command + one entry.
Engine (gotoFileStart / gotoLastLine / gotoLineStart / gotoLineEnd /
gotoFirstNonWs):
- Bound under `g` prefix Branch: gg, ge, gh, gl, gs.
- All honor extend (work the same way in Select mode).
- Common helper moveAllRanges() applies a per-range target function
through applyTarget, clamping to text bounds.
Engine (replaceChar):
- Bound to `r` via CaptureChar. For each selected range, replace every
char with the typed char. Newlines preserved (matches Helix's
destruction-gated default).
Engine (joinLines):
- Bound to `J`. For each range, find the first \n at or after it and
collapse \n + leading whitespace into a single space. Duplicate
positions collapse so multi-cursor on the same line doesn't double-join.
- Selections clamped after the edit; next read from editor re-syncs anyway.
Plugin (u / U):
- HelicaseTypedHandler intercepts `u` and `U` in Normal/Select mode,
delegates to IntelliJ's $Undo and $Redo actions via ActionManager.
Engine never sees these. Rationale: IntelliJ's undo is transaction-
aware and language-aware — way smarter than anything we'd hand-roll.
- Insert mode passes `u`/`U` through as literal chars.
11 new GotoReplaceJoinTest cases; all 41 engine tests + 8 plugin tests
still green.
Engine's normalChar dispatch shifts from open-coded `when` branches to a
[KeyMap] walk over named [Command]s. The user's stated motivation: "y is
bound to yank which does the yank" — same shape for every binding.
New types:
- Command: data class with name, description, and an (CommandContext) -> EditorState body
- CommandContext: state + extend flag + count + captured char args + syntax adapter
- KeyMap: sealed Leaf / Branch / CaptureChar tree keyed by KeyEvent
- ResolveResult: Done(command, args, consumed) / Pending / NotFound
- HelpEntry + KeyMap.continuations(events, from): list of (keys, description) for help popup
Engine changes:
- All commands declared in inner class Commands{} with name+description
- normalKeymap (public val) maps every KeyEvent to a Leaf/Branch/CaptureChar
- normalOrSelectStep pre-strips count digits, special-cases <esc>, then
delegates to normalKeymap.resolve and executes the resolved command with
a CommandContext
- Count prefix replaces the old applyWithCount: count -> 1+ digits -> arg
to CommandContext, no more per-command iteration loop
- f/F/t/T/m and friends use KeyMap.CaptureChar to grab their target chars
- mr<old><new> chains two CaptureChars
Plugin changes:
- HelpRegistry's hardcoded prefix table is gone; lookup() now walks the
engine's normalKeymap via KeyMap.continuations. Drift between popup
and dispatch is impossible by construction.
All 41 engine tests + 8 plugin tests stay green — pure refactor.
Dead code (matchTextobject, applyWithCount, swallowCommandLine) left in
place pending a follow-up cleanup pass; they're private and unreachable
but the file is busy enough that I'd rather delete in a focused commit.
EditorBridge.read rebuilds EditorState from the editor's Document and
CaretModel every keystroke — anything not stored on the editor was
silently lost. Engine.search() updated EditorState.lastSearch, but
write() never copied it back to HelicaseState, so the next read started
with lastSearch=null and 'n' fell back to a no-op.
Add lastSearch to HelicaseState (alongside mode and restoreCursor),
include it in EditorBridge.read's EditorState, and persist it back in
EditorBridge.write. Pattern matches how restoreCursor was already
handled.
New plugin test exercises the full round trip: search → write to editor →
read back → n → next match. Would have failed before this fix.
Engine additions:
- EditorState.lastSearch carries the most recent regex for n/N to repeat.
- search(state, regex, forward): forward/backward regex search per range.
Wraps around buffer when no match after cursor.
- selectInSelection(state, regex): for each range, replace with one new
range per regex match within it. No matches → range stays.
- n / N in normalChar repeat the last search forward/backward.
- Invalid regex doesn't crash — silently keeps lastSearch and bails.
Plugin additions:
- HelicaseInputPrompt — floating JBPopup with a text field anchored
near the bottom-center of the editor. Submit (Enter) fires onSubmit
with the field contents; Esc cancels.
- HelicaseTypedHandler intercepts `/` and `s` in Normal/Select mode
(NOT in Insert), opens the prompt, and on submit dispatches search()
or selectInSelection() through the engine. Insert-mode typing of
these chars passes through normally.
- HelpRegistry.SEARCH_OPS documents the new commands for future
always-on infobar / ? help.
9 engine tests cover forward / backward / wrap / regex metacharacters /
n-repeat / select-in-selection multi-match / no-match preservation /
invalid-regex resilience. All pass.
Engine refactor and new commands:
- Mode dispatch unified: Normal and Select both go through
normalOrSelectStep with an `extend` flag. Select mode passes extend=true
to every motion. Esc from Select returns to Normal.
- New normalAlt dispatcher for Alt-chord keys (<A-;>, <A-,>).
- Motions (h/l/j/k/w/b/e/W/B/E, f/F/t/T) all accept and respect extend.
In Move mode a motion collapses to a fresh 1-char block at the target;
in Extend mode the existing anchor is preserved (with the canonical
cross-anchor bump from Helix's Range::put_cursor extend=true semantics).
- applyTarget centralizes the Move-vs-Extend landing logic. Removes the
old putCursor's unconditional +1-for-backward bug that double-stacked
on consecutive Extend motions across the anchor.
- New commands:
v enter Select mode
x extend to current line; bump to next on repeat
X extend to line bounds (non-greedy)
; collapse selection to 1-char block at cursor
% select entire buffer
, keep only primary selection
<A-;> flip selection direction (swap anchor + head)
<A-,> drop primary selection (advance to next)
- HelpRegistry surfaces SELECTION_OPS for documentation (future
always-on infobar / ? help command).
8 new SelectModeTest cases pass byte-identically against the oracle.
All 31 engine tests green.
<code> elements inherited black on the IntelliJ INFO-balloon dark
background, which made the keys themselves illegible. Replaced <code>
with <span style='color:#e6e6e6;font-family:monospace;'> so the keys
are explicitly light.
Hardcoded light-on-dark for now; the balloon type fixes the background
to dark regardless of IDE theme. When we care about theming, swap in
JBColor + a query of the actual balloon bg.
BalloonBuilder defaults to a fade-in/out animation that doubles up with
our every-keystroke refresh — making the popup illegible during fast
typing. setAnimationCycle(0) makes show/hide instant.
setFadeoutTime(0) was already in place but governs auto-dismiss after
N millis, not the show/hide animation. Two separate settings.
When a multi-key command is pending (e.g. after pressing `m`), shows a
floating balloon near the bottom-right of the editor listing valid
continuation keys with descriptions. Mirrors Helix's infobox behavior.
Two pieces:
- HelpRegistry — hardcoded map of pending-key prefixes (m, mi, ma, md,
ms, mr, f, F, t, T, r) to lists of (keys, description) entries.
Designed to be the seed of a real keymap system; when we move
commands from open-coded normalChar branches into a data-driven
dispatch table, this map will be derived from the keymap itself.
- HelicaseInfoPopup — manages one IntelliJ Balloon per editor (stored
on the editor's UserData so it survives across handler calls).
refresh() reads pending state and either shows/updates the popup or
hides it. Handlers call refresh after applying state changes;
ToggleHelicaseAction also calls it to dismiss when Helicase turns off.
The popup is rendered as a small HTML table positioned near the editor's
bottom-right corner. It's pinned (no auto-fadeout, no click-outside-to-
dismiss) — lifecycle is explicit so it follows the pending buffer.
Not yet covered: actions that don't go through our handlers (e.g.
clicking elsewhere with the mouse) can leave a stale popup until the
next keystroke. Fine for now; a later refactor can hook editor focus
changes if it becomes annoying.
HelicaseStatusBarWidget reports the current editor's modal state on the
IDE status bar — Helix's statusline equivalent:
HEL: off Helicase disabled in the focused editor
HEL: NOR Normal mode, no buffered keys
HEL: INS Insert mode
HEL: NOR m m typed; engine waiting for op/pair
HEL: NOR mi mi typed; one more key to complete textobject
Registered via statusBarWidgetFactory extension. HelicaseStatus.refresh()
is a static entry point handlers call after applying state changes; it
updates the widget via WindowManager → StatusBar.updateWidget. Toggle
action also refreshes (and replaces the popup that used to confirm).
No automated tests for the widget itself yet — it depends on IDE
chrome that BasePlatformTestCase doesn't render. The state-mutation
paths it observes are already covered by HelicasePluginTest.
Bug: previous round's pass-through-in-Insert-mode logic also passed
Esc through, so once you entered Insert mode you couldn't get out.
Engine never saw <esc>; IntelliJ's default Esc handler just closed
popups and the mode field stayed at Insert.
Fix: add routeInInsertMode flag to HelicaseActionWrapper, default false,
true for Esc. Esc always routes through engine regardless of mode.
Other Insert-mode keys (<ret>, <bs>, <tab>) still pass through so
IntelliJ's smart-indent etc. keep working.
Test infrastructure: plugin module now has a test source set with
BasePlatformTestCase tests. Construct handlers locally (no need for
HelicaseStartup to fire in test env). 7 tests cover:
- i enters Insert mode
- <esc> exits Insert mode (regression for the bug above)
- i+<esc> round trip leaves mode at Normal
- Disabled Helicase bypasses everything
- mim buffers then dispatches, leaving a content selection
- <esc> clears any pending command buffer
- f<ret> finds the next newline
These run under the IntelliJ test framework, so they exercise our
handler classes against real Editor / Document / CaretModel instances
without needing a live IDE.
Previously only typed chars and Esc went through the engine; everything
else (Enter, Backspace, Tab, arrows, …) routed through IntelliJ's default
EditorActionHandlers. In Normal mode that meant pressing Enter inserted
a newline at the cursor instead of being available as a find target
(`t<ret>`), and Helix-unbound keys like arrows accidentally drove
IDE caret motion.
HelicaseActionWrapper generalizes the mode-aware dispatch:
- Helicase off, or in Insert mode: delegate to the previous handler
(preserves IntelliJ's smart-indent, auto-pair, etc.).
- Normal/Select with a non-null helixKey: append the key string
("<ret>", "<bs>", …) to the pending-keys buffer and dispatch
through the engine, same as the typed handler.
- Normal/Select with helixKey = null: silently consume — used for the
arrow / page / line-start / line-end actions that Helix leaves unbound.
HelicaseStartup wires the wrapper for Esc, Enter, Backspace, Tab,
Delete, and the 8 arrow/page/line motion actions. HelicaseEscHandler
gets retired — it was a special case of the new wrapper with
clearPending=true.
Generic PSI-backed implementation of SyntaxAdapter that walks
findElementAt(offset) up to the file root, looking for the smallest PSI
element whose text starts and ends with matching pair-like delimiters.
This works for every IntelliJ-supported language: SQL strings get the
right boundaries even with the SQL '' escape convention, Python triple-
quoted strings work via multi-char delimiter detection, Kotlin string
templates likewise. The detection rule is purely textual but inherits
the host language's lexer for free — every escape convention,
raw-string-prefix, multi-line-quote-style is handled by the parser
upstream of us.
Wired into HelicaseTypedHandler and HelicaseEscHandler: each keystroke
constructs a per-call PsiSyntaxAdapter and threads it through
Engine.apply. All PSI reads are wrapped in ReadAction for thread safety.
Engine-side test (SyntaxAdapterTest) already verifies the wiring; the
plugin side has no automated test yet — needs to be tried in DataGrip.
Extension path for the future: a LanguageContributor interface keyed by
Language can be inserted between the leaf walk and the generic
text-match check, letting per-language plugins handle nuance (Python
f-string interpolation, SQL identifier-as-scope, etc.) without touching
the engine. Not needed yet — the generic detector covers ASCII bracket
and quote pairs in every JB-native language we'd care about.
Two related bugs in the quote-pair detector that surfaced once mim/mam
got quote candidates:
1. No parity check: cursor on `,` in `("a", "b")` found the closing
quote of "a" on the left and the opening quote of "b" on the right
and called that a 'pair' even though they're from different string
literals. Fix: count unescaped quotes strictly before cursor. Even
count means cursor is between pairs (no enclosing); odd count means
cursor is inside a pair. Cursor ON a quote is disambiguated by the
same parity: even before = this is an opener; odd before = closer.
2. Escape blindness: `\"` was treated as a real boundary. Added
isBackslashEscaped() that counts consecutive preceding backslashes
modulo 2 — odd = the quote is escaped, even = it's a real boundary
(the backslashes are themselves escaped pairs).
Both fixes only matter without tree-sitter / SyntaxAdapter. With a PSI-
backed adapter installed, the engine consults enclosingScope() first and
language-specific escape rules (SQL '', raw strings, etc.) are handled
by the host parser.
2 new tests cover the bug repro (cursor on comma between strings) and
the escape case.
Pure-engine extension point for syntax-aware queries. The contract is
semantic ("enclosing scope at offset?", "string literal at offset?")
rather than tree-structural ("PsiElement", "tree-sitter Node") so any
backend can implement it — IntelliJ PSI, tree-sitter, regex hacks,
whatever the host provides.
ScopeRange carries BOTH the outer extent (with delimiters) and the inner
extent (without), so callers don't need to know delimiter length to pick
between `miX` and `maX` semantics.
Currently wired: enclosingScope() consulted by Textobjects.insideAuto and
aroundAuto. When the adapter returns a scope, it overrides plain-text
detection; when it returns null (the default), engine falls back. The
contract enumerates further scope kinds (function, class, paragraph,
comment, argument) for future textobjects but the engine doesn't consume
them yet.
Apply overloads take an explicit adapter per call. The engine's syntax
field is set/restored around each call so adapter state doesn't leak.
mim/mam: Helix's canonical algorithm (find_nth_closest_pairs_plain)
only walks bracket pairs because it leans on tree-sitter for syntax-aware
scope detection (which sees string literals as pair-like). Without
tree-sitter we approximate by probing each quote/backtick char as a
candidate pair in addition to the bracket walker, and returning the
smallest pair that encloses the entire input range. So mim/mam on
"hello" with the cursor in the word now selects the string contents.
ms<c>: surround add. For each range, insert open at from() and close
at to(). Helix's command (surround_add in commands.rs) overrides the
auto-mapped selection so the new pair chars are INCLUDED in the
resulting selection — mirrored here. mi"ms( now correctly takes
"hello world" → "(hello world)" with the parens selected.
7 hand-curated divergence tests pass against the oracle for the cases
the oracle can also handle (i.e., not the tree-sitter-only ones).
Helix's word textobject — separate from pair textobjects, dispatched
from select_textobject's match arm for chars 'w' and 'W'. miw selects
the contiguous word run under the cursor; maw extends by trailing
whitespace (or leading, if no trailing) on the same line. Long-word
variants (miW / maW) collapse Word and Punctuation into one class.
Cursor on whitespace or EOL: no-op (matches Helix). 3 hand-curated
divergence tests pass against the oracle.
Engine additions:
- I: insert at first non-whitespace char of each range's line
- A: insert at end of each range's line, with trailing-newline injection
when the line is also end-of-buffer (mirrors Helix's append_mode)
- j/k: vertical motion preserving column (no Helix-style sticky column
memory yet — successive j/k won't snap back to original column)
- StepResult gains a flag, set by every multi-key dispatcher
(m, f, t, F, T, r, g, z, :, count) when the buffer ends mid-command.
Engine.applyWithStatus returns (state, pending) so callers know to
hold or flush.
Plugin: HelicaseState carries pendingKeys. HelicaseTypedHandler buffers
each typed char into pendingKeys, dispatches the full buffer through
applyWithStatus, and either keeps the buffer (pending=true) or clears
it. HelicaseEscHandler also clears pendingKeys so Esc always escapes
out of a half-typed command.
Result in DataGrip: mi(, f<c>, t<c>, r<c>, etc. now actually work.
Multi-project Gradle root pulls in engine and plugin. Plugin uses
org.jetbrains.intellij.platform 2.5.0 with target type DB (DataGrip),
build 261.x. Kotlin bumped to 2.3.0 to match DataGrip's bundled
kotlin-stdlib; kotlinx.serialization to 1.9.0.
Plugin shape:
- HelicaseStartup registers a TypedActionHandler and an Esc
EditorActionHandler wrapping the IDE defaults; both delegate to the
previous handler when Helicase is disabled for the focused editor.
- HelicaseState is per-Editor UserData: enabled flag, current Mode,
restoreCursor flag.
- EditorBridge translates IntelliJ's Document + CaretModel ↔ the
engine's EditorState. Text edits go through WriteCommandAction; caret
positions through CaretModel.caretsAndSelections.
- ToggleHelicaseAction (Ctrl+Shift+H) flips Helicase on/off per editor.
v1 limitations:
- Modal editing is opt-in per editor (toggle action) so installing the
plugin in DataGrip doesn't immediately break normal typing.
- Each keypress is dispatched as a single-event engine command, so
multi-key sequences (mi(, f<c>, 2w, …) don't work yet — they'd
require key buffering at the plugin layer.
- No mode indicator widget. User has to remember whether they're in
Normal/Insert/Select.
Port of helix-core/src/movement.rs::word_move and CharHelpers::range_to_target.
A "word" is a maximal CharCategory run (Word = [A-Za-z0-9_], Punctuation,
Whitespace, Eol). Long-word boundaries (W/B/E) collapse Word+Punctuation
into one — only whitespace and EOL separate long-words.
Zero corpus tests exercise these motions, so divergence-tested by hand
through the oracle in WordMotionTest. 18 cases across the 6 motions
agree byte-for-byte with Helix.
Two Helix behaviors that govern how insert mode actually feels:
- `a` (append_mode) now sets a restore_cursor flag and extends head one
past the right edge of each range. `<esc>` from insert mode shrinks
forward heads back by one when the flag is set. `a<esc>` becomes a
no-op on selection state, as Helix does.
- `<ret>` in insert mode trims trailing whitespace on the current line
back to the last non-whitespace char before inserting the newline.
Indent computation (auto-indent, continue-comment) is tree-sitter-
dependent and skipped.
Also: insert_char now inserts at the BLOCK-CURSOR position (range.cursor()
= head-1 for forward, head for backward), not at head — matching
helix-term::commands::insert::insert_char. Position-mapping for inserts
uses AfterSticky/BeforeSticky per Helix's Selection::map rules, which
matters when multiple cursors insert simultaneously.
engine/build/ and engine/.gradle/ were gitignored in vrstxurk but the
checkpoints leading up to that point had already snapshotted the
generated content. Removing the residual paths here so the tip is clean.
(Earlier checkpoints still carry the spike-phase pollution; cleaning that
would require either an iterative jj absorb pass that resolves binary-file
conflicts file-by-file, or a destructive git-filter-repo run. Both are
fine to do later if the history needs to ship somewhere.)
Range.overlaps + Range.merge; Selection.normalize sorts ranges by
from(), dedup-merges overlapping ones, and relocates the primary index
to whatever post-merge range still contains it. Direct port of
helix-core/src/selection.rs::Selection::normalize.
Applied after mim/mam/md/mr so multi-cursor textobject ops that resolve
to the same pair collapse to a single range instead of producing nested
annotation markers.
Differential pass rate: 89 → 91/167.
md<c>/mdm: delete the surrounding pair chars only. Selection positions
are mapped through Helix's ChangeSet semantics — new_pos = pos minus
count of deletions strictly less than pos, with zero-width post-map
ranges expanding to 1-char block selections (Selection::normalize).
mr<old><new>: replace surrounding pair chars; same-length substitution
so selections don't shift.
mim/mam auto-detect rewritten using the canonical algorithm from
helix-core/src/surround.rs::find_nth_closest_pairs_plain. The old impl
used "smallest pair containing cursor"; Helix's is "walk forward
tracking opens; first unmatched close's pair is the candidate; accept
only if it encloses the entire range". The enclose-range check is why
(many#[ (good|]#) selects (many (good)) and not the inner (good).
Add engine build/ and .gradle/ to gitignore.
Differential pass rate: 67 → 89/167.
KeyEvent and KeyParser for Helix's key DSL (bare chars, <esc>, <C-x>,
<A-x>, common specials).
Engine implements:
- Mode entry: i (insert-before with selection-flip), a (insert-after with
block-cursor extension), <esc>
- Block-cursor h/l movement
- Char insertion + <ret> + backspace in insert mode
- Selection delete (d), change (c)
- Find-char family: f/F/t/T ported canonically from
helix-term/src/commands.rs::find_char, including the
cursor_anchor/cursor_head semantics and put_cursor extension behavior
- Textobjects mi<c>/ma<c>/mim/mam with N-th-pair count handling
(count is a parameter to find_nth_pairs_pos, not iteration)
- Count prefix dispatch with special-case for m-family
No-op key swallowing for unimplemented prefixes (r, g, z, :, f/t/F/T's
target) so partial commands don't leak into bare-key dispatch and
produce garbage edits.
Differential pass rate: 27 → 67/167.
Kotlin port of helix_core::test::print and ::plain for the
#[anchor|head]# / #(...)# annotated state format, with 4 tests
mirroring helix-core's own examples (forward/backward ranges, multi-cursor).
DifferentialTest loads cases/from_helix.json, runs each case through
both oracle and engine, compares the annotated outputs. Per-source-file
pass tally; regression floor enforced (will ratchet up as commands land).
Also silences helix-term's setup_integration_logging, which writes
log records to STDOUT (not stderr — surprising). Any JSON-on-stdout
client gets log lines mixed into responses without this. Default
HELIX_LOG_LEVEL=off at the oracle's main(); the env var can override
for debugging.
Gradle Kotlin DSL with kotlinx-serialization and JUnit 5. Core types
(Range, Selection, EditorState, Mode) live in main/, plus a stub Engine
that's an identity function — real commands come in subsequent commits.
OracleClient spawns the helicase-oracle binary as a subprocess and
exchanges line-delimited JSON over stdio. classDiscriminator = "kind"
matches serde's tag = "kind" on the Response enum. Auto-discovers the
oracle binary by walking up from cwd looking for
oracle/target/release/helicase-oracle, or honors HELICASE_ORACLE_BIN.
@TestInstance(PER_CLASS) + @BeforeAll/@AfterAll so one oracle process
serves a whole test class — Helix startup is ~100ms, too slow per-test.
rustc 1.90.0 stable (matches Helix's rust-toolchain.toml exactly) via
rust-overlay. JDK 21 LTS, Kotlin 2.3, Gradle, Python 3, jj. JAVA_HOME
exported so Gradle doesn't try to download its own JDK.
Random keypress sequences from a curated vocabulary (motions, edits,
textobjects, counts, exits — no file ops or shell-out). Each case runs
twice; outputs must be byte-identical. 2000 cases pass with zero panics
and zero non-determinism, single-threaded ~12ms/case. Seed printed for
reproducibility; HELICASE_FUZZ_CASES env var overrides default.
Three fixes the corpus build forced out:
- strip_comments is now string-literal-aware. The naive version ate `//`
occurrences inside Rust string literals (specifically: the surround
marker `//` inside indoc!{...} multi-line strings), producing
malformed annotations missing their primary marker.
- indoc!{} dedent ported correctly: source-first, before Rust escape
resolution. `\n` (the escape sequence) does NOT count as a source
newline for indent calculation — only real \n bytes do. Backslash
line-continuation preserves the trailing whitespace so indoc can see it.
- run_case_blocking wraps the oracle in catch_unwind. helix_core::test::print
panics on malformed annotations; the binary should keep serving
requests after a bad input instead of dying.
Corpus jumps 68 → 167 cases.
tools/port_helix_tests.py extracts simple test(("in", "keys", "out"))
triples from helix-term/tests/test/*.rs and emits them as JSON.
The corpus runner in oracle/tests/corpus.rs drives each case through
run_case_blocking and accepts either an exact match against Helix's
recorded out_helix or one that's equivalent modulo a trailing newline
(Helix's runtime maintains an implicit trailing \n we deliberately
don't reproduce — see equivalent_modulo_trailing_newline).
Default of 250ms dominates per-case latency: event_loop_until_idle exits
on the IdleTimeout event, which fires after the editor's idle_timer
elapses. With the default, 200 cases take 93s at 6% CPU. With 1ms it's
3s at 66% CPU — 30× speedup, now compute-bound. Faithfulness suite
runtime drops 1.57s → 0.06s.
Move types and run_case into lib.rs so the same code path can be driven
by the stdio bin AND by integration tests. Add 11 tests ported verbatim
from helix-term/tests/test/movement.rs that assert byte-identical output
against Helix's own hardcoded expectations.
Supports both plain-text initial state (initial_text/initial_selections)
and Helix-annotated state (initial_helix). The annotated round-trip
output is included in every response for compact diffing.
One request per line in, one response per line out. Stateless: each
request builds a fresh Application. Responses are a tagged enum with
kind: "ok" carrying text/selections/primary/mode, or kind: "err"
carrying a message.
Three gotchas the docs do not surface:
- helix-term needs feature = ["integration"] enabled to use TestBackend
in place of the real TerminaBackend; otherwise Application::new requires
a TTY.
- The event loop wants termina::event::{Event, KeyEvent} (or crossterm on
Windows) — helix_view::input::Event is a separate type despite being
re-exported alongside parse_macro.
- helix-event's integration_test feature keys runtime-local globals by
tokio::runtime::Handle::current().id(). Reusing one runtime across
Application instances leaks event handlers and panics inside
transaction::compose. Solution: build a fresh multi_thread runtime per
case.
Pinned to Helix master @ 8c41b116 via git rev. The bin is a stub
that does nothing — the point is to verify Helix-core, helix-view,
helix-term, and helix-loader will compile as library deps from outside
the workspace. They do.
IntelliJ Platform Gradle Plugin's local cache (downloaded IDE artifacts,
update locks, javaagent locks). Regenerated on every Gradle build, no
reason for them to be in the tree.
Adds .intellijPlatform/ to .gitignore and removes the three files that
had slipped in (a self-update.lock, coroutines-javaagent.lock, and a
bundled-module XML descriptor).
Two workflow files under .tangled/workflows/, both running under nixery
with jdk21 / rustc / cargo / git from nixpkgs.
ci.yml fires on every push: builds the Rust oracle binary, runs the
engine test suite (which includes the differential-vs-Helix tests via
OracleClient), then builds the plugin distribution zip. Catches broken
commits before they get tagged.
release.yml fires on tag push: same steps, then attaches the resulting
Helicase-*.zip to the tag via spindle-artifact (third-party tool at
git+https://tangled.org/regnault.dev/spindle-artifact). Under the hood
spindle-artifact authenticates with ATProto via app password, uploads
the zip as a blob, and creates a record pinning the blob to the named
tag — Tangled's actual mechanism for per-tag attachments.
The publishing account's handle (isaaccorbrey.com) is hardcoded in
release.yml's `environment` block; the secret half (ATPROTO_APP_PASSWORD)
lives in repo secrets and Spindle injects it at run time.
One known fragility: `git tag | tac | head -1` fishes "the latest tag"
out of the runner rather than reading the trigger context directly.
That works while CI is fired by the tag actually being latest. If
non-chronological tags ever appear, the upload step picks the wrong
one; worth swapping to a Spindle-exposed variable when one becomes
available.
README at repo root covering features, sideload install (incl. Settings →
Helicase first-run tip), product compatibility table (all JetBrains
IntelliJ-Platform IDEs), and dual-license pointer. Compatibility table
verified for DataGrip; rest marked "needs verification" — plugin only
depends on `com.intellij.modules.platform` so loading should be uniform.
Dual MIT + Apache 2.0 license at repo root (LICENSE-MIT, LICENSE-APACHE)
following the Rust-ecosystem convention. README's "licensed under MIT or
Apache 2.0, at your option" links point at both files.
CHANGELOG.md in Keep-a-Changelog format with custom section names that
read more naturally for an IDE-plugin audience: Release highlights,
Breaking changes, Deprecations, New features, Fixed bugs. Initial
[Unreleased] entry summarizes the 0.1.0 surface — Helix config ingest,
chord intake, dynamic minor modes, gw jump labels, LSP gotos, : command
mode, persistent settings, registers, logger, engine command surface,
and the differential-testing harness.
Plugin build pipeline (plugin/build.gradle.kts):
- `org.jetbrains.changelog` plugin reads CHANGELOG.md and renders the
current version's block (or [Unreleased] fallback) as HTML into the
plugin descriptor's <change-notes>, so the IDE plugin-info dialog
shows the same release notes the repo does.
- Single-source `version = "0.1.0"` at the top drives plugin descriptor
version, changelog plugin version lookup, and distribution zip name.
- `buildPlugin` task's archive base renamed from "plugin" to "Helicase"
so the sideload zip is `Helicase-0.1.0.zip` (matches the README's
install instructions).
- `repositoryUrl = "https://tangled.org/isaaccorbrey.com/helicase"` for
the changelog plugin's auto-linking of references.
Hoist the enabled flag from per-editor HelicaseState to app-wide
HelicaseSettings. Opening a new editor used to leave Helicase off
until you re-toggled — wrong: modal editing is conceptually one
state that spans the IDE. Now every editor inherits the live value;
toggling via Ctrl-Shift-H flips it everywhere.
Persistence: HelicasePersistentSettings (PersistentStateComponent
on the application level) stores `enabledByDefault` to
`helicase.xml` in IntelliJ's config dir. Survives restarts.
HelicaseConfigurable registers a Settings → Helicase panel with
the single checkbox; apply() writes to the persistent state AND
updates the live HelicaseSettings.enabled so the change takes
effect immediately rather than waiting for the next restart.
Startup precedence: persistent default loads first, then TOML
[helicase].enabled overrides if explicitly set. Power users
managing everything via config.toml stay in control; everyone
else gets a sensible GUI knob.
Toggle action (Ctrl-Shift-H) is session-only — it doesn't
write through to persistent. "Set in Preferences" = permanent,
"press the toggle" = transient. Matches the Vim/Helix mental
model where config = permanent, runtime = session.
Also includes the InvokeIdeCommand handler dispatch in the typed
and action wrappers (file-coupled to the .enabled hoist; can't be
cleanly split).
Engine: new PendingAction.InvokeIdeCommand(name) effect — a generic
"plugin, please run the IDE command called <name>". Lets g-family LSP
commands delegate without the engine knowing about IntelliJ action IDs.
Four engine commands emit it for gd / gy / gi / gr; engine state is
otherwise unchanged.
Plugin: HelicaseCommand resolves the four names to
GotoDeclaration / GotoTypeDeclaration / GotoImplementation /
ShowUsages — daily-driver code-nav actions that already exist in
the platform and respect language semantics (so they work for SQL
identifiers in DataGrip, references in Kotlin, etc.).
Also remap two <space> bindings whose original mappings missed what
Helix actually does:
- <space>f file_picker -> GotoFile (fuzzy file finder), was
RecentFiles which is closer to a buffer-list popup.
- <space>b buffer_picker -> RecentFiles, which IS Helix's
per-buffer popup intent; was Switcher (Ctrl-Tab cycle).
(InvokeIdeCommand dispatching landed in the global-settings commit on
top because the typed/action handler edits are intertwined with the
enabled-flag hoist; engine emits correctly even at this commit, but
the side effect fires once the next commit lands.)
HelicaseInfoPopup used to surface only engine-side pending keys via
HelpRegistry (so `m`-family and `g`-family hints rendered). Now it
checks the plugin-side pending minor mode FIRST and, if active,
renders entries straight from that submap.
Plugin minor modes get the same hint UX `m`/`g` always had — for
free, including any user-defined submap. Action entries show their
binding string (`:bp`, `@<esc>vCi`); Submap entries show `→ <name>`
so it's clear that key opens another mode.
HelicaseChordDispatcher refreshes the popup on every minor-mode
state change — enter, exit, and action-fired-from-inside. Last case
was the bug behind the user-reported "popup stays after `<space>f`
opens the file picker": Action firing cleared pendingMinorMode but
never refreshed the popup, so the stale hint floated until another
key event triggered a state recompute.
Startup validator was flagging `workspace_symbol -> GotoClass` as missing
in every session. GotoClass is Java-flavored (no Java classes in
DataGrip), but Helix's `<space>S` is "workspace symbol picker" — the
project-wide variant of `<space>s`'s "file-local symbol picker".
Remap:
- `<space>s` (file-local) -> FileStructurePopup
- `<space>S` (workspace) -> GotoSymbol
Picks up Helix's symbol-picker semantics in DataGrip terms and gets rid
of the persistent missing-action warning at startup.
Engine: `gw` emits a one-shot PendingAction.GotoWord effect on the
returned state. Keeps the engine pure — the action is just a tagged
enum the plugin inspects after `engine.apply`; no IntelliJ code in the
engine module.
Plugin: JumpLabelOverlay implements Helix's `jump_to_word` algorithm —
scan the visible viewport for 2+ char words, sort by distance from
cursor (closest first), assign 2-char labels from the configured
alphabet (`alphabet[i/N] + alphabet[i%N]`, so words[0] = `jj` for the
user's `jklfdsau…` alphabet), render the labels on the IDE's glass
pane as opaque JComponent overlays.
Glass pane rather than RangeHighlighter or contentComponent child:
the editor's text-fragment paint pass overdrew labels in both other
approaches — both glyphs ghosted through. Glass pane is the topmost
layer of the IDE window so Swing paints it over everything.
After the first key matches, prune labels that don't start with that
char so only the still-in-play set stays visible. Second key locks in
the jump; Esc / non-alphabet key dismisses.
Config: `[editor].jump-label-alphabet` from TOML config feeds
HelicaseSettings. Defaults to Helix's `"abcdefghijklmnopqrstuvwxyz"`.
Loader falls back to `~/.config/helix/config.toml` when there's no
Helicase-specific config — users can point the same file at both
without duplication.
Reference impl: helix-term/src/commands.rs::jump_to_word at rev 8c41b11.
Two intentional simplifications vs upstream: distance-sort instead of
explicit alternating forward/backward walk (same result for typical
layouts), and engine emits the same effect regardless of mode (Helix
has separate `extend_to_word` for Select).
Replace the flat chord -> binding-string map with a Binding sealed type:
Action wraps a binding string and fires on press; Submap wraps a name +
inner table and represents a minor mode. The chord dispatcher tracks
the active submap in HelicaseState.pendingMinorMode — every keypress
while pending looks up against that map instead of the top-level table,
Esc cancels at any nesting depth, unbound keys silently abort, and
nested Submaps stack so submaps can themselves contain submaps.
TOML loader walks tables recursively: string values become Actions,
sub-tables become Submaps. Both `space = { f = ":..." }` inline-table
syntax and `[keys.normal."<space>"]` nested-section syntax work. User
overrides merge per-level so e.g. setting `<space>.f` doesn't blow
away the rest of the default <space> map.
Chord canonicalization now emits a Helix-style string for ANY key, not
just modifier-chords/F-keys: plain `f` -> `"f"`, space -> `"<space>"`,
shifted-? -> `"?"`, Esc/Ret/Tab -> `"<esc>"` etc. Plain chars only
trigger the dispatcher when bound at the top level (`<space>` is the
only default) or when in a pending minor mode. KEY_TYPED is suppressed
following a consumed printable KEY_PRESSED so the chord doesn't ALSO
type its character through TypedActionHandler.
Default <space> submap mirrors Helix's leader bindings via IntelliJ
action delegates: f -> RecentFiles, F -> GotoFile, b -> Switcher,
/ -> FindInPath, a -> ShowIntentionActions, k -> QuickJavaDoc,
r -> RenameElement, s -> GotoSymbol, S -> GotoClass, d -> GotoNextError,
? -> GotoAction.
HelicaseCommand.validate() probes every IDE-action-delegating command
at startup, logs warnings for any IDs not registered (catches typos,
version drift, and DataGrip-vs-IDEA-Ultimate differences before the
user reaches for a broken binding).
Sift through every command implemented so far and confirm behavior
against the real Helix oracle. Six divergences found and corrected:
- `*` (`search_selection`): used the literal selection contents,
regex-escaped. Was expanding 1-char block cursors to the surrounding
word with \\b boundaries — that's `<A-*>`
(`search_selection_detect_word_boundaries`), a separate Helix command.
- `o` / `O` (`open_below` / `open_above`): match Helix's indent-on-
newline behavior. Source line's indent in VISUAL columns is measured,
then `indentString` (default `\\t`) is laid down to match. Range
shape is a 1-char forward block with `restoreCursor = true` instead
of a point. The "copy leading whitespace verbatim" behavior I'd
written was my own invention.
- `>` / `<` (`indent`/`unindent`): default `indentString` changed from
4 spaces to `\\t` — Helix's actual default. Dedent now measures
leading whitespace in visual columns (tab → next tab stop, space →
one column) and removes one shiftwidth's worth, not one char's worth.
- `ge` (`goto_last_line`): trailing newline does NOT count as a
separate "last line". A buffer ending in `\\n` lands `ge` on the
start of the last content line, not the empty position past the
final newline. (Earlier "fix" had been my mistaken interpretation
of the user's report.)
- `r<c>` (`replace`): replaces every char in selection, INCLUDING
newlines. Comment claiming "newlines preserved (matches Helix)" was
wrong — the oracle disagrees.
Tests converted to `checkOracle`: IndentTest (10), CopySelectionOnLineTest
(7), OpenLineTest (7), GotoReplaceJoinTest (12). The QuoteMimAndSurroundAddTest
quote-mim/mam cases stay hand-asserted with a clearer comment explaining
WHY — Helix needs tree-sitter to see a string literal as a pair, and
the oracle's test syntax loader has no grammar for our plaintext buffers.
Engine compensates with a plain-text quote-parity walker the oracle can't
match. Surround-add (ms<c>) was and stays oracle-checked.
Indent/dedent tests use `<gt>` / `<lt>` for the `>` / `<` keys: bare
`>` and `<` are reserved as escape delimiters in Helix's `parse_macro`.
This audit is the value differential testing was meant to deliver —
six wrong assumptions caught and corrected without manual reproduction.
A forward block range with `head > text.length` (the phantom empty
position past the last char — produced by e.g. `o`/`O` opening a new
line) used to throw `StringIndexOutOfBoundsException` from the marker
insertion. Helix's reference annotation renders the same case as
`#[|]#` (point at end), so the engine and the oracle's annotated
output couldn't be diffed for these cases.
Patch: clamp anchor and head to [0, text.length]; if they collapse
equal after clamping, emit the point form. Preserves all in-bounds
behavior; only changes the previously-erroring out-of-bounds case
to render the same string Helix does.
Three layers that together let the user reach for the chords/commands in
their actual Helix config.toml and have them work in Helicase.
`:`-mode (HelicaseCommand + HelicaseTypedHandler): typing `:` in Normal
or Select opens a small input prompt; submission resolves through
HelicaseCommand. Built-in mappings cover the user's daily-driver bindings:
`:bp` / `:bn` (PreviousEditorTab / NextEditorTab), `:bc` / `:q` (close),
`:w` (save), `:wq`, `:moveLineUp` / `:moveLineDown` (PSI-aware via
IntelliJ's MoveLineUp/Down), `:reload` (re-reads config.toml without
restart). Action invocation uses DataManager.getDataContext on
`editor.component` — the outer Swing component carries the EDITOR_WINDOW
data the tab actions consult, which the inner `contentComponent` does
not.
Chord intake (HelicaseBinding + HelicaseChordDispatcher): captures
Ctrl-/Alt-/F-key shortcuts that TypedActionHandler and EditorActionManager
can't see. Registered through IdeEventQueue.addDispatcher (NOT the AWT
KeyboardFocusManager) so we run BEFORE IntelliJ's keymap dispatcher
fires — otherwise Ctrl-K would trigger both our binding AND IntelliJ's
default "Checkin Project" action and splice text behind our back. Each
chord maps to a Helix-style binding string: `":<cmd>"` invokes a colon-
command directly; `"@<segments>"` walks a mixed key-feed + colon-command
sequence into the engine.
TOML config (HelicaseConfig): reads `~/.config/helicase/config.toml`
(or `$XDG_CONFIG_HOME/helicase/config.toml`, or `$HELICASE_CONFIG`),
parses with tomlj, merges `[keys.normal]` / `[keys.insert]` /
`[keys.select]` tables over the built-in defaults. User can drop their
literal Helix config.toml at the Helicase path — non-keymap sections
(theme, editor settings) are silently ignored.
One logfmt line per keystroke, carrying every relevant field as
key=value pairs. Pattern is canonical-log-lines / "logging sucks":
stop scattering a handler's context across N debug lines, collect it
into one structured event you can grep.
HelicaseLog.event { ... } builds the event via a fluent EventBuilder
with field() and a state(prefix, EditorState) shortcut for before/after
dumps. Off by default; gated through IntelliJ's Logger so it goes
silent until the user enables `#io.helicase` via Help → Diagnostic
Tools → Debug Log Settings. Inline + early-return for zero overhead
when disabled.
Instrumented in HelicaseTypedHandler and HelicaseActionWrapper — both
emit `evt=key source=typed|action key=<k> keys=<buffer>
before.{mode,primary,ranges,search} after.{...} pending=<bool>
apply_us=<microseconds>`. apply_us is wall-clock of just the engine
call, useful for spotting unexpectedly slow commands.
Side benefit confirmed in dogfooding: unrouted keys (`:`, `q`, `L`
etc.) show up as no-op events with unchanged before/after state,
making it obvious what the user reached for vs. what's actually
implemented. Surfacing the gap, not just bugs.
Two pieces of state that need to live in HelicaseState because they don't
survive being rebuilt from IntelliJ's CaretModel each keystroke:
insertRanges — In Insert mode, EditorBridge renders point carets (no
selection highlight) at cursor() rather than head. Without selection
highlight, anchors would be lost on the next read; insertRanges
shadows them so Esc can restore the full Helix-style range. Also
positions the caret at cursor() instead of head: head for a forward
range is one PAST the last char, so rendering there put the caret on
the wrong line when typing at end-of-line.
primaryIndex — Helix's primary cursor doesn't reliably round-trip
through IntelliJ's CaretModel. Setting `caretsAndSelections` doesn't
let us pick a primary by index, and `allCarets` returns by offset
order on read. Without shadowing this, `*vn` once would work but `n`
again would re-search from the original (wrong) primary and dedupe
against an existing match — `n` appeared "stuck" forward. Plus reorder
the caretStates list so primary comes first (IntelliJ uses the first
entry as its visual primary), and scroll the viewport to the primary
on every write so `n`/`N` motion is visible even when no new range is
added.
Five engine additions tied together by what the user's daily-driver Helix
config reaches for.
`*` — search-cursor-word/selection. Sets EditorState.lastSearch from the
primary range: literal-escaped contents for multi-char selections; for a
1-char block on a word char, expands to the surrounding word with \b
boundaries (so block-cursor `*n` jumps word-to-word, not every-char-of-
the-buffer). Doesn't navigate; `n`/`N` handle that.
`C` / `<A-C>` — duplicate primary selection onto next/previous line at
the same columns, preserving direction. Count multiplies. Lines too
short to hit the start column are skipped (Helix's behavior).
Select-mode `n` / `N` — primary always advances by one match in
direction; if the destination is already a range, just move primary
there; otherwise add the match as a new range and become the primary.
Walking forward grows the cursor set, walking back retraces, going
further back than the start grows in that direction. User-described
example: `_ _ _ #[4]# _ _` + `nn` adds 5,6; `NN` retraces; `N` adds 3.
`<bs>` — multi-cursor backspace. Engine deletes the char before each
range's `cursor()`, maps anchor/head through the deletion set. Plus
`HelicaseStartup` routes `<ret>` and `<bs>` through the engine in
Insert mode too — IntelliJ's smart-indent on Enter would otherwise
desync the Insert-mode shadow ranges, leaving selections at stale
offsets when Esc restores them.
Tests cover all of the above plus the n/N walk-and-retrace pattern.
Daily-driver SQL editing additions:
- o / O open a new line below / above the cursor and enter Insert mode,
copying the source line's leading whitespace as the new indent. (Helix
uses tree-sitter for smarter heuristics; copy-prev-indent is the
no-language-hook default.)
- > / < indent / dedent every line touched by any selection, with count
multiplying the shift. The position-mapping rule for indent edits
diverges from the general edit-mapping: a position that lands on a
line-start where indent was inserted STAYS at that line-start, so
repeated > on the same selection keeps shifting it. Default
shiftwidth is 4 spaces; CommandContext.indentString is the override
hook for a future plugin wire-up against CodeStyleSettings.
- Ctrl-c in Normal/Select mode toggles line comment by delegating to
IntelliJ's CommentByLineComment action (so SQL gets `--` and Kotlin
gets `//` for free). HelicaseDelegateActionWrapper is the general
mechanism — falls through to the previous handler in Insert mode so
Copy still works while typing.
EditorBridge now computes a minimal-diff replaceString instead of clobbering
the whole document with setText, and applies the text edit and caret sync
inside a single WriteCommandAction. Result: one paste keystroke produces
one undo entry (was two), and incremental highlighting / PSI updates don't
re-fire on unchanged buffer regions.
HelicaseTypedHandler.performIdeAction now calls action.update(event) and
only fires actionPerformed when the presentation is enabled. Without this,
pressing u (or U) with an empty history raised an 'IDE error occurred'
notification — which matches neither Helix's behavior (no-op at boundary)
nor any sensible modal-editor expectation.
Engine: Registers store keyed by char, RegisterContent {values, linewise}.
Yank writes per-range slots into the default register " (or "<c> for named
registers); paste reads them with i%N cycling so multi-cursor paste is
symmetric to multi-cursor yank. Linewise detection (all values end with
\n AND all ranges start at line boundary) drives paste positioning —
linewise lands on the next/previous line, charwise lands at to()/from().
Count multiplies the per-range slot inline. R substitutes selection with
register contents using the same pairing rules.
Plugin: AwtClipboard bridges the engine's Clipboard interface to the
host clipboard for "+ and "*. Yank to either writes through; paste from
either falls back to a live read when the engine slot is empty.
HelicaseState carries the Registers across keystrokes alongside
lastSearch, since EditorBridge.read otherwise rebuilds EditorState from
the IDE document model fresh.
The blackhole register _ swallows writes and reads as empty.
Filling in commands users hit every minute. All easy now that the
keymap is data-driven — each addition is a Command + one entry.
Engine (gotoFileStart / gotoLastLine / gotoLineStart / gotoLineEnd /
gotoFirstNonWs):
- Bound under `g` prefix Branch: gg, ge, gh, gl, gs.
- All honor extend (work the same way in Select mode).
- Common helper moveAllRanges() applies a per-range target function
through applyTarget, clamping to text bounds.
Engine (replaceChar):
- Bound to `r` via CaptureChar. For each selected range, replace every
char with the typed char. Newlines preserved (matches Helix's
destruction-gated default).
Engine (joinLines):
- Bound to `J`. For each range, find the first \n at or after it and
collapse \n + leading whitespace into a single space. Duplicate
positions collapse so multi-cursor on the same line doesn't double-join.
- Selections clamped after the edit; next read from editor re-syncs anyway.
Plugin (u / U):
- HelicaseTypedHandler intercepts `u` and `U` in Normal/Select mode,
delegates to IntelliJ's $Undo and $Redo actions via ActionManager.
Engine never sees these. Rationale: IntelliJ's undo is transaction-
aware and language-aware — way smarter than anything we'd hand-roll.
- Insert mode passes `u`/`U` through as literal chars.
11 new GotoReplaceJoinTest cases; all 41 engine tests + 8 plugin tests
still green.
Engine's normalChar dispatch shifts from open-coded `when` branches to a
[KeyMap] walk over named [Command]s. The user's stated motivation: "y is
bound to yank which does the yank" — same shape for every binding.
New types:
- Command: data class with name, description, and an (CommandContext) -> EditorState body
- CommandContext: state + extend flag + count + captured char args + syntax adapter
- KeyMap: sealed Leaf / Branch / CaptureChar tree keyed by KeyEvent
- ResolveResult: Done(command, args, consumed) / Pending / NotFound
- HelpEntry + KeyMap.continuations(events, from): list of (keys, description) for help popup
Engine changes:
- All commands declared in inner class Commands{} with name+description
- normalKeymap (public val) maps every KeyEvent to a Leaf/Branch/CaptureChar
- normalOrSelectStep pre-strips count digits, special-cases <esc>, then
delegates to normalKeymap.resolve and executes the resolved command with
a CommandContext
- Count prefix replaces the old applyWithCount: count -> 1+ digits -> arg
to CommandContext, no more per-command iteration loop
- f/F/t/T/m and friends use KeyMap.CaptureChar to grab their target chars
- mr<old><new> chains two CaptureChars
Plugin changes:
- HelpRegistry's hardcoded prefix table is gone; lookup() now walks the
engine's normalKeymap via KeyMap.continuations. Drift between popup
and dispatch is impossible by construction.
All 41 engine tests + 8 plugin tests stay green — pure refactor.
Dead code (matchTextobject, applyWithCount, swallowCommandLine) left in
place pending a follow-up cleanup pass; they're private and unreachable
but the file is busy enough that I'd rather delete in a focused commit.
EditorBridge.read rebuilds EditorState from the editor's Document and
CaretModel every keystroke — anything not stored on the editor was
silently lost. Engine.search() updated EditorState.lastSearch, but
write() never copied it back to HelicaseState, so the next read started
with lastSearch=null and 'n' fell back to a no-op.
Add lastSearch to HelicaseState (alongside mode and restoreCursor),
include it in EditorBridge.read's EditorState, and persist it back in
EditorBridge.write. Pattern matches how restoreCursor was already
handled.
New plugin test exercises the full round trip: search → write to editor →
read back → n → next match. Would have failed before this fix.
Engine additions:
- EditorState.lastSearch carries the most recent regex for n/N to repeat.
- search(state, regex, forward): forward/backward regex search per range.
Wraps around buffer when no match after cursor.
- selectInSelection(state, regex): for each range, replace with one new
range per regex match within it. No matches → range stays.
- n / N in normalChar repeat the last search forward/backward.
- Invalid regex doesn't crash — silently keeps lastSearch and bails.
Plugin additions:
- HelicaseInputPrompt — floating JBPopup with a text field anchored
near the bottom-center of the editor. Submit (Enter) fires onSubmit
with the field contents; Esc cancels.
- HelicaseTypedHandler intercepts `/` and `s` in Normal/Select mode
(NOT in Insert), opens the prompt, and on submit dispatches search()
or selectInSelection() through the engine. Insert-mode typing of
these chars passes through normally.
- HelpRegistry.SEARCH_OPS documents the new commands for future
always-on infobar / ? help.
9 engine tests cover forward / backward / wrap / regex metacharacters /
n-repeat / select-in-selection multi-match / no-match preservation /
invalid-regex resilience. All pass.
Engine refactor and new commands:
- Mode dispatch unified: Normal and Select both go through
normalOrSelectStep with an `extend` flag. Select mode passes extend=true
to every motion. Esc from Select returns to Normal.
- New normalAlt dispatcher for Alt-chord keys (<A-;>, <A-,>).
- Motions (h/l/j/k/w/b/e/W/B/E, f/F/t/T) all accept and respect extend.
In Move mode a motion collapses to a fresh 1-char block at the target;
in Extend mode the existing anchor is preserved (with the canonical
cross-anchor bump from Helix's Range::put_cursor extend=true semantics).
- applyTarget centralizes the Move-vs-Extend landing logic. Removes the
old putCursor's unconditional +1-for-backward bug that double-stacked
on consecutive Extend motions across the anchor.
- New commands:
v enter Select mode
x extend to current line; bump to next on repeat
X extend to line bounds (non-greedy)
; collapse selection to 1-char block at cursor
% select entire buffer
, keep only primary selection
<A-;> flip selection direction (swap anchor + head)
<A-,> drop primary selection (advance to next)
- HelpRegistry surfaces SELECTION_OPS for documentation (future
always-on infobar / ? help command).
8 new SelectModeTest cases pass byte-identically against the oracle.
All 31 engine tests green.
<code> elements inherited black on the IntelliJ INFO-balloon dark
background, which made the keys themselves illegible. Replaced <code>
with <span style='color:#e6e6e6;font-family:monospace;'> so the keys
are explicitly light.
Hardcoded light-on-dark for now; the balloon type fixes the background
to dark regardless of IDE theme. When we care about theming, swap in
JBColor + a query of the actual balloon bg.
BalloonBuilder defaults to a fade-in/out animation that doubles up with
our every-keystroke refresh — making the popup illegible during fast
typing. setAnimationCycle(0) makes show/hide instant.
setFadeoutTime(0) was already in place but governs auto-dismiss after
N millis, not the show/hide animation. Two separate settings.
When a multi-key command is pending (e.g. after pressing `m`), shows a
floating balloon near the bottom-right of the editor listing valid
continuation keys with descriptions. Mirrors Helix's infobox behavior.
Two pieces:
- HelpRegistry — hardcoded map of pending-key prefixes (m, mi, ma, md,
ms, mr, f, F, t, T, r) to lists of (keys, description) entries.
Designed to be the seed of a real keymap system; when we move
commands from open-coded normalChar branches into a data-driven
dispatch table, this map will be derived from the keymap itself.
- HelicaseInfoPopup — manages one IntelliJ Balloon per editor (stored
on the editor's UserData so it survives across handler calls).
refresh() reads pending state and either shows/updates the popup or
hides it. Handlers call refresh after applying state changes;
ToggleHelicaseAction also calls it to dismiss when Helicase turns off.
The popup is rendered as a small HTML table positioned near the editor's
bottom-right corner. It's pinned (no auto-fadeout, no click-outside-to-
dismiss) — lifecycle is explicit so it follows the pending buffer.
Not yet covered: actions that don't go through our handlers (e.g.
clicking elsewhere with the mouse) can leave a stale popup until the
next keystroke. Fine for now; a later refactor can hook editor focus
changes if it becomes annoying.
HelicaseStatusBarWidget reports the current editor's modal state on the
IDE status bar — Helix's statusline equivalent:
HEL: off Helicase disabled in the focused editor
HEL: NOR Normal mode, no buffered keys
HEL: INS Insert mode
HEL: NOR m m typed; engine waiting for op/pair
HEL: NOR mi mi typed; one more key to complete textobject
Registered via statusBarWidgetFactory extension. HelicaseStatus.refresh()
is a static entry point handlers call after applying state changes; it
updates the widget via WindowManager → StatusBar.updateWidget. Toggle
action also refreshes (and replaces the popup that used to confirm).
No automated tests for the widget itself yet — it depends on IDE
chrome that BasePlatformTestCase doesn't render. The state-mutation
paths it observes are already covered by HelicasePluginTest.
Bug: previous round's pass-through-in-Insert-mode logic also passed
Esc through, so once you entered Insert mode you couldn't get out.
Engine never saw <esc>; IntelliJ's default Esc handler just closed
popups and the mode field stayed at Insert.
Fix: add routeInInsertMode flag to HelicaseActionWrapper, default false,
true for Esc. Esc always routes through engine regardless of mode.
Other Insert-mode keys (<ret>, <bs>, <tab>) still pass through so
IntelliJ's smart-indent etc. keep working.
Test infrastructure: plugin module now has a test source set with
BasePlatformTestCase tests. Construct handlers locally (no need for
HelicaseStartup to fire in test env). 7 tests cover:
- i enters Insert mode
- <esc> exits Insert mode (regression for the bug above)
- i+<esc> round trip leaves mode at Normal
- Disabled Helicase bypasses everything
- mim buffers then dispatches, leaving a content selection
- <esc> clears any pending command buffer
- f<ret> finds the next newline
These run under the IntelliJ test framework, so they exercise our
handler classes against real Editor / Document / CaretModel instances
without needing a live IDE.
Previously only typed chars and Esc went through the engine; everything
else (Enter, Backspace, Tab, arrows, …) routed through IntelliJ's default
EditorActionHandlers. In Normal mode that meant pressing Enter inserted
a newline at the cursor instead of being available as a find target
(`t<ret>`), and Helix-unbound keys like arrows accidentally drove
IDE caret motion.
HelicaseActionWrapper generalizes the mode-aware dispatch:
- Helicase off, or in Insert mode: delegate to the previous handler
(preserves IntelliJ's smart-indent, auto-pair, etc.).
- Normal/Select with a non-null helixKey: append the key string
("<ret>", "<bs>", …) to the pending-keys buffer and dispatch
through the engine, same as the typed handler.
- Normal/Select with helixKey = null: silently consume — used for the
arrow / page / line-start / line-end actions that Helix leaves unbound.
HelicaseStartup wires the wrapper for Esc, Enter, Backspace, Tab,
Delete, and the 8 arrow/page/line motion actions. HelicaseEscHandler
gets retired — it was a special case of the new wrapper with
clearPending=true.
Generic PSI-backed implementation of SyntaxAdapter that walks
findElementAt(offset) up to the file root, looking for the smallest PSI
element whose text starts and ends with matching pair-like delimiters.
This works for every IntelliJ-supported language: SQL strings get the
right boundaries even with the SQL '' escape convention, Python triple-
quoted strings work via multi-char delimiter detection, Kotlin string
templates likewise. The detection rule is purely textual but inherits
the host language's lexer for free — every escape convention,
raw-string-prefix, multi-line-quote-style is handled by the parser
upstream of us.
Wired into HelicaseTypedHandler and HelicaseEscHandler: each keystroke
constructs a per-call PsiSyntaxAdapter and threads it through
Engine.apply. All PSI reads are wrapped in ReadAction for thread safety.
Engine-side test (SyntaxAdapterTest) already verifies the wiring; the
plugin side has no automated test yet — needs to be tried in DataGrip.
Extension path for the future: a LanguageContributor interface keyed by
Language can be inserted between the leaf walk and the generic
text-match check, letting per-language plugins handle nuance (Python
f-string interpolation, SQL identifier-as-scope, etc.) without touching
the engine. Not needed yet — the generic detector covers ASCII bracket
and quote pairs in every JB-native language we'd care about.
Two related bugs in the quote-pair detector that surfaced once mim/mam
got quote candidates:
1. No parity check: cursor on `,` in `("a", "b")` found the closing
quote of "a" on the left and the opening quote of "b" on the right
and called that a 'pair' even though they're from different string
literals. Fix: count unescaped quotes strictly before cursor. Even
count means cursor is between pairs (no enclosing); odd count means
cursor is inside a pair. Cursor ON a quote is disambiguated by the
same parity: even before = this is an opener; odd before = closer.
2. Escape blindness: `\"` was treated as a real boundary. Added
isBackslashEscaped() that counts consecutive preceding backslashes
modulo 2 — odd = the quote is escaped, even = it's a real boundary
(the backslashes are themselves escaped pairs).
Both fixes only matter without tree-sitter / SyntaxAdapter. With a PSI-
backed adapter installed, the engine consults enclosingScope() first and
language-specific escape rules (SQL '', raw strings, etc.) are handled
by the host parser.
2 new tests cover the bug repro (cursor on comma between strings) and
the escape case.
Pure-engine extension point for syntax-aware queries. The contract is
semantic ("enclosing scope at offset?", "string literal at offset?")
rather than tree-structural ("PsiElement", "tree-sitter Node") so any
backend can implement it — IntelliJ PSI, tree-sitter, regex hacks,
whatever the host provides.
ScopeRange carries BOTH the outer extent (with delimiters) and the inner
extent (without), so callers don't need to know delimiter length to pick
between `miX` and `maX` semantics.
Currently wired: enclosingScope() consulted by Textobjects.insideAuto and
aroundAuto. When the adapter returns a scope, it overrides plain-text
detection; when it returns null (the default), engine falls back. The
contract enumerates further scope kinds (function, class, paragraph,
comment, argument) for future textobjects but the engine doesn't consume
them yet.
Apply overloads take an explicit adapter per call. The engine's syntax
field is set/restored around each call so adapter state doesn't leak.
mim/mam: Helix's canonical algorithm (find_nth_closest_pairs_plain)
only walks bracket pairs because it leans on tree-sitter for syntax-aware
scope detection (which sees string literals as pair-like). Without
tree-sitter we approximate by probing each quote/backtick char as a
candidate pair in addition to the bracket walker, and returning the
smallest pair that encloses the entire input range. So mim/mam on
"hello" with the cursor in the word now selects the string contents.
ms<c>: surround add. For each range, insert open at from() and close
at to(). Helix's command (surround_add in commands.rs) overrides the
auto-mapped selection so the new pair chars are INCLUDED in the
resulting selection — mirrored here. mi"ms( now correctly takes
"hello world" → "(hello world)" with the parens selected.
7 hand-curated divergence tests pass against the oracle for the cases
the oracle can also handle (i.e., not the tree-sitter-only ones).
Helix's word textobject — separate from pair textobjects, dispatched
from select_textobject's match arm for chars 'w' and 'W'. miw selects
the contiguous word run under the cursor; maw extends by trailing
whitespace (or leading, if no trailing) on the same line. Long-word
variants (miW / maW) collapse Word and Punctuation into one class.
Cursor on whitespace or EOL: no-op (matches Helix). 3 hand-curated
divergence tests pass against the oracle.
Engine additions:
- I: insert at first non-whitespace char of each range's line
- A: insert at end of each range's line, with trailing-newline injection
when the line is also end-of-buffer (mirrors Helix's append_mode)
- j/k: vertical motion preserving column (no Helix-style sticky column
memory yet — successive j/k won't snap back to original column)
- StepResult gains a flag, set by every multi-key dispatcher
(m, f, t, F, T, r, g, z, :, count) when the buffer ends mid-command.
Engine.applyWithStatus returns (state, pending) so callers know to
hold or flush.
Plugin: HelicaseState carries pendingKeys. HelicaseTypedHandler buffers
each typed char into pendingKeys, dispatches the full buffer through
applyWithStatus, and either keeps the buffer (pending=true) or clears
it. HelicaseEscHandler also clears pendingKeys so Esc always escapes
out of a half-typed command.
Result in DataGrip: mi(, f<c>, t<c>, r<c>, etc. now actually work.
Multi-project Gradle root pulls in engine and plugin. Plugin uses
org.jetbrains.intellij.platform 2.5.0 with target type DB (DataGrip),
build 261.x. Kotlin bumped to 2.3.0 to match DataGrip's bundled
kotlin-stdlib; kotlinx.serialization to 1.9.0.
Plugin shape:
- HelicaseStartup registers a TypedActionHandler and an Esc
EditorActionHandler wrapping the IDE defaults; both delegate to the
previous handler when Helicase is disabled for the focused editor.
- HelicaseState is per-Editor UserData: enabled flag, current Mode,
restoreCursor flag.
- EditorBridge translates IntelliJ's Document + CaretModel ↔ the
engine's EditorState. Text edits go through WriteCommandAction; caret
positions through CaretModel.caretsAndSelections.
- ToggleHelicaseAction (Ctrl+Shift+H) flips Helicase on/off per editor.
v1 limitations:
- Modal editing is opt-in per editor (toggle action) so installing the
plugin in DataGrip doesn't immediately break normal typing.
- Each keypress is dispatched as a single-event engine command, so
multi-key sequences (mi(, f<c>, 2w, …) don't work yet — they'd
require key buffering at the plugin layer.
- No mode indicator widget. User has to remember whether they're in
Normal/Insert/Select.
Port of helix-core/src/movement.rs::word_move and CharHelpers::range_to_target.
A "word" is a maximal CharCategory run (Word = [A-Za-z0-9_], Punctuation,
Whitespace, Eol). Long-word boundaries (W/B/E) collapse Word+Punctuation
into one — only whitespace and EOL separate long-words.
Zero corpus tests exercise these motions, so divergence-tested by hand
through the oracle in WordMotionTest. 18 cases across the 6 motions
agree byte-for-byte with Helix.
Two Helix behaviors that govern how insert mode actually feels:
- `a` (append_mode) now sets a restore_cursor flag and extends head one
past the right edge of each range. `<esc>` from insert mode shrinks
forward heads back by one when the flag is set. `a<esc>` becomes a
no-op on selection state, as Helix does.
- `<ret>` in insert mode trims trailing whitespace on the current line
back to the last non-whitespace char before inserting the newline.
Indent computation (auto-indent, continue-comment) is tree-sitter-
dependent and skipped.
Also: insert_char now inserts at the BLOCK-CURSOR position (range.cursor()
= head-1 for forward, head for backward), not at head — matching
helix-term::commands::insert::insert_char. Position-mapping for inserts
uses AfterSticky/BeforeSticky per Helix's Selection::map rules, which
matters when multiple cursors insert simultaneously.
engine/build/ and engine/.gradle/ were gitignored in vrstxurk but the
checkpoints leading up to that point had already snapshotted the
generated content. Removing the residual paths here so the tip is clean.
(Earlier checkpoints still carry the spike-phase pollution; cleaning that
would require either an iterative jj absorb pass that resolves binary-file
conflicts file-by-file, or a destructive git-filter-repo run. Both are
fine to do later if the history needs to ship somewhere.)
Range.overlaps + Range.merge; Selection.normalize sorts ranges by
from(), dedup-merges overlapping ones, and relocates the primary index
to whatever post-merge range still contains it. Direct port of
helix-core/src/selection.rs::Selection::normalize.
Applied after mim/mam/md/mr so multi-cursor textobject ops that resolve
to the same pair collapse to a single range instead of producing nested
annotation markers.
Differential pass rate: 89 → 91/167.
md<c>/mdm: delete the surrounding pair chars only. Selection positions
are mapped through Helix's ChangeSet semantics — new_pos = pos minus
count of deletions strictly less than pos, with zero-width post-map
ranges expanding to 1-char block selections (Selection::normalize).
mr<old><new>: replace surrounding pair chars; same-length substitution
so selections don't shift.
mim/mam auto-detect rewritten using the canonical algorithm from
helix-core/src/surround.rs::find_nth_closest_pairs_plain. The old impl
used "smallest pair containing cursor"; Helix's is "walk forward
tracking opens; first unmatched close's pair is the candidate; accept
only if it encloses the entire range". The enclose-range check is why
(many#[ (good|]#) selects (many (good)) and not the inner (good).
Add engine build/ and .gradle/ to gitignore.
Differential pass rate: 67 → 89/167.
KeyEvent and KeyParser for Helix's key DSL (bare chars, <esc>, <C-x>,
<A-x>, common specials).
Engine implements:
- Mode entry: i (insert-before with selection-flip), a (insert-after with
block-cursor extension), <esc>
- Block-cursor h/l movement
- Char insertion + <ret> + backspace in insert mode
- Selection delete (d), change (c)
- Find-char family: f/F/t/T ported canonically from
helix-term/src/commands.rs::find_char, including the
cursor_anchor/cursor_head semantics and put_cursor extension behavior
- Textobjects mi<c>/ma<c>/mim/mam with N-th-pair count handling
(count is a parameter to find_nth_pairs_pos, not iteration)
- Count prefix dispatch with special-case for m-family
No-op key swallowing for unimplemented prefixes (r, g, z, :, f/t/F/T's
target) so partial commands don't leak into bare-key dispatch and
produce garbage edits.
Differential pass rate: 27 → 67/167.
Kotlin port of helix_core::test::print and ::plain for the
#[anchor|head]# / #(...)# annotated state format, with 4 tests
mirroring helix-core's own examples (forward/backward ranges, multi-cursor).
DifferentialTest loads cases/from_helix.json, runs each case through
both oracle and engine, compares the annotated outputs. Per-source-file
pass tally; regression floor enforced (will ratchet up as commands land).
Also silences helix-term's setup_integration_logging, which writes
log records to STDOUT (not stderr — surprising). Any JSON-on-stdout
client gets log lines mixed into responses without this. Default
HELIX_LOG_LEVEL=off at the oracle's main(); the env var can override
for debugging.
Gradle Kotlin DSL with kotlinx-serialization and JUnit 5. Core types
(Range, Selection, EditorState, Mode) live in main/, plus a stub Engine
that's an identity function — real commands come in subsequent commits.
OracleClient spawns the helicase-oracle binary as a subprocess and
exchanges line-delimited JSON over stdio. classDiscriminator = "kind"
matches serde's tag = "kind" on the Response enum. Auto-discovers the
oracle binary by walking up from cwd looking for
oracle/target/release/helicase-oracle, or honors HELICASE_ORACLE_BIN.
@TestInstance(PER_CLASS) + @BeforeAll/@AfterAll so one oracle process
serves a whole test class — Helix startup is ~100ms, too slow per-test.
Random keypress sequences from a curated vocabulary (motions, edits,
textobjects, counts, exits — no file ops or shell-out). Each case runs
twice; outputs must be byte-identical. 2000 cases pass with zero panics
and zero non-determinism, single-threaded ~12ms/case. Seed printed for
reproducibility; HELICASE_FUZZ_CASES env var overrides default.
Three fixes the corpus build forced out:
- strip_comments is now string-literal-aware. The naive version ate `//`
occurrences inside Rust string literals (specifically: the surround
marker `//` inside indoc!{...} multi-line strings), producing
malformed annotations missing their primary marker.
- indoc!{} dedent ported correctly: source-first, before Rust escape
resolution. `\n` (the escape sequence) does NOT count as a source
newline for indent calculation — only real \n bytes do. Backslash
line-continuation preserves the trailing whitespace so indoc can see it.
- run_case_blocking wraps the oracle in catch_unwind. helix_core::test::print
panics on malformed annotations; the binary should keep serving
requests after a bad input instead of dying.
Corpus jumps 68 → 167 cases.
tools/port_helix_tests.py extracts simple test(("in", "keys", "out"))
triples from helix-term/tests/test/*.rs and emits them as JSON.
The corpus runner in oracle/tests/corpus.rs drives each case through
run_case_blocking and accepts either an exact match against Helix's
recorded out_helix or one that's equivalent modulo a trailing newline
(Helix's runtime maintains an implicit trailing \n we deliberately
don't reproduce — see equivalent_modulo_trailing_newline).
Default of 250ms dominates per-case latency: event_loop_until_idle exits
on the IdleTimeout event, which fires after the editor's idle_timer
elapses. With the default, 200 cases take 93s at 6% CPU. With 1ms it's
3s at 66% CPU — 30× speedup, now compute-bound. Faithfulness suite
runtime drops 1.57s → 0.06s.
Move types and run_case into lib.rs so the same code path can be driven
by the stdio bin AND by integration tests. Add 11 tests ported verbatim
from helix-term/tests/test/movement.rs that assert byte-identical output
against Helix's own hardcoded expectations.
Supports both plain-text initial state (initial_text/initial_selections)
and Helix-annotated state (initial_helix). The annotated round-trip
output is included in every response for compact diffing.
Three gotchas the docs do not surface:
- helix-term needs feature = ["integration"] enabled to use TestBackend
in place of the real TerminaBackend; otherwise Application::new requires
a TTY.
- The event loop wants termina::event::{Event, KeyEvent} (or crossterm on
Windows) — helix_view::input::Event is a separate type despite being
re-exported alongside parse_macro.
- helix-event's integration_test feature keys runtime-local globals by
tokio::runtime::Handle::current().id(). Reusing one runtime across
Application instances leaks event handlers and panics inside
transaction::compose. Solution: build a fresh multi_thread runtime per
case.