Commits
When the cursor lands on a '\n' — reachable in normal Helix use by
pressing l past the last visible char of a line — the wire contract
reports cursor_start=N cursor_end=N+1 with text[N]=='\n'. My renderer
emitted <span class="cursor">\n</span> which inside <pre> renders as
a plain line break with no visible highlight glyph. Fix: when the
cursor grapheme is a line terminator, substitute a non-breaking space
inside the span for the highlight, then emit the actual newline as
plain text so the line still breaks. Same treatment for empty slice
(cursor past EOF).
Also fix insert-mode caret in the same scenario: a border-left on a
'\n' has no glyph to attach to, so switch to the caret-eof zero-width
marker when the char under the caret is a line terminator.
Rust-side regression test cursor_can_sit_on_line_terminator_after_moving_
past_last_char pins the wire contract so future rendering work can
trust that (cursor_start..cursor_end) may span exactly a '\n'.
renderSnapshot now consumes selectionStart/selectionEnd alongside
cursorStart/cursorEnd. In normal/select mode it draws a subtle
.selection tint over the body and a stronger .cursor highlight over
the block-cursor grapheme inside it — so multi-char selections (w, e, x)
finally show up visually. In insert mode it applies a border-left to
the character at cursor position (no more inline-block caret, which
disrupted the monospace grid and caused the c cursor to visually shift
a pixel). EOF caret handled separately with a zero-width span.
Fixes the two things user asked for: highlighting renders, and the c
cursor is now visually where the underlying data says it is.
primary_cursor_range now derives cursor position from Range::cursor(text) —
the same accessor helix-view's CursorCache uses — instead of raw
Range::head. After commands like c that delete a selection and enter
insert mode, Selection::ensure_invariants width-1-expands the collapsed
range, so Range::head lands one grapheme past the visual cursor. Range::
cursor accounts for that; using it fixes an off-by-1 for c specifically
and matches Helix's own render path in every mode.
New primary_selection_range returns the primary Range body
(from, to), so the frontend can draw the actual selection highlight —
previously we only exposed the cursor block, so multi-grapheme selections
(w, e, x, W, etc.) rendered as if collapsed. Snapshot gains
selectionStart / selectionEnd; the JSON-shape test lists them alongside
cursorStart / cursorEnd.
Three regression tests cover the wire contract: word_selection_reports_
selection_body_range, insert_mode_reports_zero_width_cursor_at_head, and
change_command_leaves_caret_at_start_of_deletion (the c off-by-1
reproduction).
184 tests lifted from helix-term/tests/test/{auto_pairs, command_line,
commands, movement, splits, commands/insert, commands/movement,
commands/write, commands/reverse_selection_contents,
commands/rotate_selection_contents}.rs at the pinned Helix revision.
Adaptations from upstream:
- test((...)).await? → test((...)) (fresnel harness is sync).
- #[tokio::test(flavor = "multi_thread")] → "current_thread".
- The (input, keys, expected, LineFeedHandling::AsIs) 4-tuple maps
to our (input, keys, expected, LineFeeds::AsIs). helpers.rs grew
an IntoTestCase trait so both 3-tuple and 4-tuple forms work
verbatim.
- LINE_END constant matches upstream.
Ignored tests: 91 total, each with a specific reason. Categories: file
I/O commands (:w, :wq, :r) — 35; : command-line prompt — ~20;
tree-sitter grammars — ~15; splits — 5; macros — 1;
AppBuilder::with_config custom keymaps — 4; one known-failing test
(2[<space>u undo edge case) pending investigation. The ignored bucket
is the Phase 1 backlog: cargo test -- --ignored prints it.
Passing: 93 tests including all 44 non-ignored auto_pairs cases,
find_char and find_char_line_ending (which stress on_next_key_callback,
just wired), surround_inside_pair / surround_around_pair /
test_surround_delete / test_surround_replace / mim / mam / mdm, all
non-ignored commands.rs (search, extend, delete, join, paste,
duplication), and both rotate_selection_contents variants.
Helix commands that need an argument character — find_char (f<c>),
replace (r<c>), the surround/match submenu (mim, mam, mdm), and a few
others — call cx.on_next_key(|cx, ev| ...) inside their command body.
The next key event then bypasses the keymap and goes straight to that
callback. helix-term's EditorView handles this by taking the callback
off the Context after each dispatch, stashing it on the view, and
draining it before the next keymap lookup.
Wire the same on EditorSession: an Option<(OnKeyCallback,
OnKeyCallbackKind)> field, drained at the top of handle_key, refilled
after every command execution (both keymap dispatch and insert-mode
insert_char fallback). Unlocks all of Helix's on-next-key surface: 12
integration tests that were failing on this alone (mim, mam, mdm,
mrm, mdm, fl, tl, Fl, Tl, and their variants) now pass verbatim.
New workspace crate whose only role is holding cross-crate tests behind
a single integration binary (per the `rust` skill's build-cache rule).
The harness — expect(input, keys, expected) — reuses Helix's public DSL
primitives verbatim:
helix_core::test::{print, plain} selection-annotated string ↔ (text, Selection)
helix_view::input::parse_macro "ihello<esc>" → Vec<KeyEvent>
This means test cases from helix-term's own integration suite can be
lifted directly and run against fresnel_core::EditorSession — Helix's
observable behavior is the oracle, not a fresnel-shaped approximation
of it.
Initial coverage: insert-mode cursor position (lifted verbatim), basic
motion (l, h, counted 5l), gg to document start, ge to last-line start,
i-then-letters buffer mutation, i<esc> round-trip. `ge` in particular
confirmed that Helix's own behavior is "jump to line start, not to
last position of buffer" — which was one of the two symptoms reported.
Snapshot exposed a raw `cursor` head offset, which the frontend then
treated as "position to highlight" — off-by-one for forward-facing
selections and visually wrong in insert mode (where the cursor should be
a between-character caret, not a highlighted grapheme).
Replace with cursorStart/cursorEnd: the character range the cursor
visually covers per Helix's block-cursor semantics. In normal/select
mode it's a width-1 grapheme range starting at Range::cursor(text); in
insert mode it collapses to head..head. Frontend renders a highlight
span for the range and a zero-width caret for the collapsed case.
Also expose current_selection() and set_state() on EditorSession — the
next commit's integration-test harness needs to seed a specific starting
selection and read back the final one to compare against Helix's
`test::plain` DSL.
The frontend's ad-hoc key-to-command map is gone — a DOM KeyboardEvent
gets translated to Helix's key-event format (arrow keys, esc, C-w, S-tab)
and handed to the new editor_key command. The response carries the
KeymapResult outcome and a fresh Snapshot; the UI renders mode, count,
and any pending keystrokes so multi-key sequences are visible in-flight.
Named-key translation lives in a NAMED_KEYS table; anything else falls
through to the character path so Shift-encoded uppercase and punctuation
work without special-casing. Cmd/browser-reserved chords stay unhandled
so DevTools and reload keep working.
EditorSession::handle_key runs a KeyEvent through
helix_term::keymap::Keymaps::default(), distilling helix-term's own
EditorView dispatch: normal-mode count-digit accumulation on editor.count,
insert-mode fallback via commands::insert::insert_char, and typed
KeyOutcome variants for pending / cancelled / not-found so callers can
render mode-appropriate feedback. Snapshot gains mode, count, and
pending_keys so the frontend can show what the keymap's waiting for.
The actor gains a HandleKey message that takes Helix's own key-event
format (the string syntax that appears in config.toml keymaps). Reusing
Helix's FromStr avoids a bespoke wire format and keeps the boundary
narrow.
Not-yet-wired: the . repeat operator, macros, on_next_key_callback, and
pseudo-pending state. Each of those is a discrete follow-up.
Manages an EditorHandle in Tauri state and exposes editor_snapshot /
editor_execute commands. The frontend maps arrow keys and h/j/k/l/w/b to
Helix movement commands and rerenders the buffer with an inline cursor
marker after each command. Enough to prove keystrokes drive a real
helix_view::Editor end-to-end; the modal, composable keymap will land
when helix-term's Keymaps dispatcher gets wired in.
fresnel_core::EditorSession constructs a helix_view::Editor with a single
scratch document, opens a view on it, and exposes execute_static() so
callers can run entries from MappableCommand::STATIC_COMMAND_LIST against
it. handlers::minimal builds the Handlers value helix_view::Editor::new
requires by dropping every receiver — commands that mutate buffer/
selection state work, LSP/completion round-trips silently no-op, which is
the intended behavior at this stage.
helix_view::Editor's config field is Arc<dyn DynAccess<Config>> without
+ Send + Sync, so the editor can't sit behind a shared Mutex. actor::spawn
confines it to a dedicated worker thread and exposes a Send + Sync
EditorHandle that speaks a small typed request/response protocol. Snapshot
and ExecuteError are serde::Serialize so a Tauri command handler can hand
them straight over the IPC boundary.
fresnel needs helix-term's MappableCommand roster and Keymaps dispatcher.
Its build.rs otherwise fetches and compiles ~300 bundled tree-sitter
grammars, one of which reproducibly fails to link on nix's toolchain under
the concurrent build. Set HELIX_DISABLE_AUTO_GRAMMAR_BUILD=1 at the
workspace level to skip that step — fresnel drives syntax highlighting
through Pierre's Shiki path in Phase 2 and its own tree-sitter for
syntax-aware collapse, so the bundled grammars aren't needed.
Also set DEVELOPER_DIR so nix's gcc can find dsymutil for debug-info
bundling on macOS.
Trivial vertical slice: a Tauri command backed by fresnel-core's
helix_core::Rope-backed Buffer, invoked from a Vite/TypeScript frontend.
Proves the Cargo workspace + Tauri IPC + frontend pipeline works before
wiring up LSP, jj, or helix-term's commands/keymap.
helix-term isn't depended on yet — see PLAN.md's Phase 1 notes for the
dsymutil grammar-build issue found while investigating it.
Update Helix and Pierre-diffs sections with spike findings verified by
direct source inspection.
When the cursor lands on a '\n' — reachable in normal Helix use by
pressing l past the last visible char of a line — the wire contract
reports cursor_start=N cursor_end=N+1 with text[N]=='\n'. My renderer
emitted <span class="cursor">\n</span> which inside <pre> renders as
a plain line break with no visible highlight glyph. Fix: when the
cursor grapheme is a line terminator, substitute a non-breaking space
inside the span for the highlight, then emit the actual newline as
plain text so the line still breaks. Same treatment for empty slice
(cursor past EOF).
Also fix insert-mode caret in the same scenario: a border-left on a
'\n' has no glyph to attach to, so switch to the caret-eof zero-width
marker when the char under the caret is a line terminator.
Rust-side regression test cursor_can_sit_on_line_terminator_after_moving_
past_last_char pins the wire contract so future rendering work can
trust that (cursor_start..cursor_end) may span exactly a '\n'.
renderSnapshot now consumes selectionStart/selectionEnd alongside
cursorStart/cursorEnd. In normal/select mode it draws a subtle
.selection tint over the body and a stronger .cursor highlight over
the block-cursor grapheme inside it — so multi-char selections (w, e, x)
finally show up visually. In insert mode it applies a border-left to
the character at cursor position (no more inline-block caret, which
disrupted the monospace grid and caused the c cursor to visually shift
a pixel). EOF caret handled separately with a zero-width span.
Fixes the two things user asked for: highlighting renders, and the c
cursor is now visually where the underlying data says it is.
primary_cursor_range now derives cursor position from Range::cursor(text) —
the same accessor helix-view's CursorCache uses — instead of raw
Range::head. After commands like c that delete a selection and enter
insert mode, Selection::ensure_invariants width-1-expands the collapsed
range, so Range::head lands one grapheme past the visual cursor. Range::
cursor accounts for that; using it fixes an off-by-1 for c specifically
and matches Helix's own render path in every mode.
New primary_selection_range returns the primary Range body
(from, to), so the frontend can draw the actual selection highlight —
previously we only exposed the cursor block, so multi-grapheme selections
(w, e, x, W, etc.) rendered as if collapsed. Snapshot gains
selectionStart / selectionEnd; the JSON-shape test lists them alongside
cursorStart / cursorEnd.
Three regression tests cover the wire contract: word_selection_reports_
selection_body_range, insert_mode_reports_zero_width_cursor_at_head, and
change_command_leaves_caret_at_start_of_deletion (the c off-by-1
reproduction).
184 tests lifted from helix-term/tests/test/{auto_pairs, command_line,
commands, movement, splits, commands/insert, commands/movement,
commands/write, commands/reverse_selection_contents,
commands/rotate_selection_contents}.rs at the pinned Helix revision.
Adaptations from upstream:
- test((...)).await? → test((...)) (fresnel harness is sync).
- #[tokio::test(flavor = "multi_thread")] → "current_thread".
- The (input, keys, expected, LineFeedHandling::AsIs) 4-tuple maps
to our (input, keys, expected, LineFeeds::AsIs). helpers.rs grew
an IntoTestCase trait so both 3-tuple and 4-tuple forms work
verbatim.
- LINE_END constant matches upstream.
Ignored tests: 91 total, each with a specific reason. Categories: file
I/O commands (:w, :wq, :r) — 35; : command-line prompt — ~20;
tree-sitter grammars — ~15; splits — 5; macros — 1;
AppBuilder::with_config custom keymaps — 4; one known-failing test
(2[<space>u undo edge case) pending investigation. The ignored bucket
is the Phase 1 backlog: cargo test -- --ignored prints it.
Passing: 93 tests including all 44 non-ignored auto_pairs cases,
find_char and find_char_line_ending (which stress on_next_key_callback,
just wired), surround_inside_pair / surround_around_pair /
test_surround_delete / test_surround_replace / mim / mam / mdm, all
non-ignored commands.rs (search, extend, delete, join, paste,
duplication), and both rotate_selection_contents variants.
Helix commands that need an argument character — find_char (f<c>),
replace (r<c>), the surround/match submenu (mim, mam, mdm), and a few
others — call cx.on_next_key(|cx, ev| ...) inside their command body.
The next key event then bypasses the keymap and goes straight to that
callback. helix-term's EditorView handles this by taking the callback
off the Context after each dispatch, stashing it on the view, and
draining it before the next keymap lookup.
Wire the same on EditorSession: an Option<(OnKeyCallback,
OnKeyCallbackKind)> field, drained at the top of handle_key, refilled
after every command execution (both keymap dispatch and insert-mode
insert_char fallback). Unlocks all of Helix's on-next-key surface: 12
integration tests that were failing on this alone (mim, mam, mdm,
mrm, mdm, fl, tl, Fl, Tl, and their variants) now pass verbatim.
New workspace crate whose only role is holding cross-crate tests behind
a single integration binary (per the `rust` skill's build-cache rule).
The harness — expect(input, keys, expected) — reuses Helix's public DSL
primitives verbatim:
helix_core::test::{print, plain} selection-annotated string ↔ (text, Selection)
helix_view::input::parse_macro "ihello<esc>" → Vec<KeyEvent>
This means test cases from helix-term's own integration suite can be
lifted directly and run against fresnel_core::EditorSession — Helix's
observable behavior is the oracle, not a fresnel-shaped approximation
of it.
Initial coverage: insert-mode cursor position (lifted verbatim), basic
motion (l, h, counted 5l), gg to document start, ge to last-line start,
i-then-letters buffer mutation, i<esc> round-trip. `ge` in particular
confirmed that Helix's own behavior is "jump to line start, not to
last position of buffer" — which was one of the two symptoms reported.
Snapshot exposed a raw `cursor` head offset, which the frontend then
treated as "position to highlight" — off-by-one for forward-facing
selections and visually wrong in insert mode (where the cursor should be
a between-character caret, not a highlighted grapheme).
Replace with cursorStart/cursorEnd: the character range the cursor
visually covers per Helix's block-cursor semantics. In normal/select
mode it's a width-1 grapheme range starting at Range::cursor(text); in
insert mode it collapses to head..head. Frontend renders a highlight
span for the range and a zero-width caret for the collapsed case.
Also expose current_selection() and set_state() on EditorSession — the
next commit's integration-test harness needs to seed a specific starting
selection and read back the final one to compare against Helix's
`test::plain` DSL.
The frontend's ad-hoc key-to-command map is gone — a DOM KeyboardEvent
gets translated to Helix's key-event format (arrow keys, esc, C-w, S-tab)
and handed to the new editor_key command. The response carries the
KeymapResult outcome and a fresh Snapshot; the UI renders mode, count,
and any pending keystrokes so multi-key sequences are visible in-flight.
Named-key translation lives in a NAMED_KEYS table; anything else falls
through to the character path so Shift-encoded uppercase and punctuation
work without special-casing. Cmd/browser-reserved chords stay unhandled
so DevTools and reload keep working.
EditorSession::handle_key runs a KeyEvent through
helix_term::keymap::Keymaps::default(), distilling helix-term's own
EditorView dispatch: normal-mode count-digit accumulation on editor.count,
insert-mode fallback via commands::insert::insert_char, and typed
KeyOutcome variants for pending / cancelled / not-found so callers can
render mode-appropriate feedback. Snapshot gains mode, count, and
pending_keys so the frontend can show what the keymap's waiting for.
The actor gains a HandleKey message that takes Helix's own key-event
format (the string syntax that appears in config.toml keymaps). Reusing
Helix's FromStr avoids a bespoke wire format and keeps the boundary
narrow.
Not-yet-wired: the . repeat operator, macros, on_next_key_callback, and
pseudo-pending state. Each of those is a discrete follow-up.
Manages an EditorHandle in Tauri state and exposes editor_snapshot /
editor_execute commands. The frontend maps arrow keys and h/j/k/l/w/b to
Helix movement commands and rerenders the buffer with an inline cursor
marker after each command. Enough to prove keystrokes drive a real
helix_view::Editor end-to-end; the modal, composable keymap will land
when helix-term's Keymaps dispatcher gets wired in.
fresnel_core::EditorSession constructs a helix_view::Editor with a single
scratch document, opens a view on it, and exposes execute_static() so
callers can run entries from MappableCommand::STATIC_COMMAND_LIST against
it. handlers::minimal builds the Handlers value helix_view::Editor::new
requires by dropping every receiver — commands that mutate buffer/
selection state work, LSP/completion round-trips silently no-op, which is
the intended behavior at this stage.
helix_view::Editor's config field is Arc<dyn DynAccess<Config>> without
+ Send + Sync, so the editor can't sit behind a shared Mutex. actor::spawn
confines it to a dedicated worker thread and exposes a Send + Sync
EditorHandle that speaks a small typed request/response protocol. Snapshot
and ExecuteError are serde::Serialize so a Tauri command handler can hand
them straight over the IPC boundary.
fresnel needs helix-term's MappableCommand roster and Keymaps dispatcher.
Its build.rs otherwise fetches and compiles ~300 bundled tree-sitter
grammars, one of which reproducibly fails to link on nix's toolchain under
the concurrent build. Set HELIX_DISABLE_AUTO_GRAMMAR_BUILD=1 at the
workspace level to skip that step — fresnel drives syntax highlighting
through Pierre's Shiki path in Phase 2 and its own tree-sitter for
syntax-aware collapse, so the bundled grammars aren't needed.
Also set DEVELOPER_DIR so nix's gcc can find dsymutil for debug-info
bundling on macOS.
Trivial vertical slice: a Tauri command backed by fresnel-core's
helix_core::Rope-backed Buffer, invoked from a Vite/TypeScript frontend.
Proves the Cargo workspace + Tauri IPC + frontend pipeline works before
wiring up LSP, jj, or helix-term's commands/keymap.
helix-term isn't depended on yet — see PLAN.md's Phase 1 notes for the
dsymutil grammar-build issue found while investigating it.