summaryrefslogtreecommitdiff
path: root/docs/helix-keys.md
Commit message (Collapse)AuthorAge
...
* trunk: resume before the Reload experimentGabriel Schneider2026-09-15
| | | | Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
* Refactor panes and filesystem; replace FUSE with 9PGabriel Schneider2026-09-07
| | | | | | Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
* pipe: helix's other three shell commands, and its newline ruleGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `|` was the only one of helix's five. The other four differ in exactly two things — whether the selection is stdin, and where the output lands — so they are one action carrying a `PipeBehavior` rather than four code paths: | stdin is the selection, output REPLACES it (had this) A-| stdin is the selection, output discarded shell_pipe_to ! no stdin, output inserted BEFORE each selection shell_insert_output A-! no stdin, output appended AFTER each selection shell_append_output Each arms the same visible tag-tail prompt with its own marker (`|`, `|-`, `!`, `!+`) so the prompt says which one you are in — they take the same command line and do very different things to the buffer. Two helix rules came with them. A behaviour that sends no stdin runs the command ONCE and every cursor gets that one answer (helix's `shell_output` cache): ten cursors and `date` give ten identical stamps rather than ten forks racing to produce one. And a command that put a trailing newline on a selection which did not have one has it taken back off — that is what keeps a one-line `| tr a-z A-Z` from becoming two lines. The existing multi-range test moved with that rule and now pins it deliberately. In all three writing behaviours the OUTPUT is what ends up selected, keeping the original range's direction, so an operator can follow straight on from what the command just produced. `$` (`shell_keep_pipe` — drop the selections whose command exited nonzero) is still missing: it needs a per-selection verdict and the runner's answer is atomic. Noted in docs/helix-keys.md beside the `$` divergence already there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* An edited row keeps its colours, four copies of forkShell become one, and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc stops recentring ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
* syntax + pdf_pane + file_pane: prose grammars paint, pdf Esc cancels, gj/gk ↵Gabriel Schneider2026-08-25
| | | | walk wrapped rows
* modal + file_pane: rework editing math and pane behavior, config/syntax ↵Gabriel Schneider2026-08-18
| | | | additions, unit tests
* big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web ↵Gabriel Schneider2026-08-18
| | | | + snapshot refresh
* look: richer path/range parsing, pdf rendering, corner-drag and stepgrain ↵Gabriel Schneider2026-08-15
| | | | snapshots
* shaders: -Dprebuilt-shaders, so a gui build needs no Vulkan SDKGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | glslc is the one build input that wants a tool a stock machine does not have, and it is also the input that changes least often: eight GLSL files that have outlived several rewrites of everything around them. Asking every machine that wants to run the SDL shell for shaderc is the wrong trade. The SPIR-V is now COMMITTED, under shaders/prebuilt/, and -Dprebuilt-shaders embeds that copy instead of shelling out. The default stays the honest one -- compile the shaders that are actually in the tree -- because the flag trades a dependency for a freshness problem: with it on, the .glsl sources are not build inputs at all, so editing one changes nothing. `zig build shaders` is the other half, and it is deliberately independent of -Dplatform: it recompiles every shader and writes the result back into the tracked directory, so whoever changes a shader refreshes the cache on a machine that has the compiler and commits the diff. `jj diff shaders/prebuilt` after it is the freshness check -- empty means the cache was already current. The shader list is also spelled once now (gui_shaders): the eight embeds, the eight glslc runs and the refresh step all read it, so adding a shader is a name there plus the @embedFile in gui.zig, not three edits in two places. Verified: -Dplatform=gui -Dprebuilt-shaders builds with glslc absent from PATH, and image-harness passes on that binary -- real SDL GPU pipelines built from the committed SPIR-V, 512 source pixels read back. The default gui build still runs the eight glslc steps; tty runs none. The committed bytes are identical to a fresh glslc run, and `zig build shaders` is idempotent.
* docs: the tutor taught three keystrokes wrong, and the rest had driftedGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The documentation had gone stale in the ordinary way -- claims that were true when they were written and that nothing since had been obliged to re-read. Some of them were load-bearing. THE TUTOR. It still said there is no multi-cursor, that NextColor cycles three themes, and that its practice blocks "are also run as unit tests (generated from this file by tutor_gen)" -- a tool that appears nowhere in the tree, and nothing anywhere parses a `# keys:` block. Left alone, that claim is what makes the next wrong block survive. Three of those blocks WERE wrong, and all three for one reason: since the helix motion model landed, w/e/f/t SELECT the range they cross, so `i` after one inserts at the SELECTION'S START. `w i Z esc` on "foo bar" gives "Zfoo bar", not the "foo Zbar" the file promised. They were written against a vim reading of the same keys. Every block in the file has now been run through `zig build hxdiff` against the real core and matches byte for byte, and the trap itself is written down in 3.3 rather than left to be rediscovered. The tutor gains a PART 4 for everything added since it was written -- PDF panes, the in-process ZLS backend, themes and fonts, the startup file -- and PART 3 gains counts (and which keys ignore one), f/F/t/T, the whole g table (bare `G` is a no-op; `ge` is the START of the last line), multiple cursors and the s/S regex pair, `m`, `]`/`[`, `|`, insert mode, and all fifty leader paths. THE REST. design.typ's line table claimed 7,626 lines against a real 38,048, and its rows did not sum to its own total; its Event/Effect boundary contract -- the part a shell author writes against -- named four variants that do not exist and omitted fourteen that do. lsp.md's probe count. config.md's theme-name rules, which as written could not reach a zed theme at all. helix-keys.md's Skipped section, holding five families that have since landed. macos.md's menu bar, undocumented, along with sixteen other claims. web.md on what the browser build can actually do. SOURCE COMMENTS that had rotted alongside them: `tag_normal` is a space, not the `•` its own comment describes; Wrap is ON by default, not off; a FontSel row is SELECTED by n and RUN by Tab, not run by n; the SPC paths in lsp.zig lost their `l` group prefix when the language group moved; and the differential suites are 481 and 561 cases, not 360 and 440. TWO THINGS FOUND BY DOCUMENTING THEM, both left standing and written down rather than papered over. Typing `[^\n]` at an s/S prompt panics: the live preview compiles every prefix, and `[^\` indexes an empty slice in mvzr's parseCharSet. Both the tutor and a waiver recommended that pattern as the workaround for `.` matching a newline; they now say what it costs and what would make it sayable. And `Exec` is a builtin, so an `Exec` line in the startup config types that command into a shell before the first frame -- the tutor said nothing in that file is ever sent to one. Nine adversarial reviews over two rounds, each with the hxdiff harness to execute what it doubted. The second round exists because the first round's fixes needed checking too, and it caught three regressions of my own -- one of them a probe count I had "corrected" away from the truth. Verified: unit-test, snap 87/87, hxdiff 481/0, hxparity 561/0, mupdf-check. docs/design.pdf regenerated. The tutor's first seventeen lines are byte- identical, which is what tutor.golden pins.
* clipboard, n/N and the tty prompt: three things that were half-wiredGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three changes that all turned out to be the same shape -- a feature that worked in one direction, or for one pane kind, and quietly did not in the others. CLIPBOARD. Every register write emitted set_clipboard, so deleting one character threw away whatever the desktop was holding; multi-cursor yank took the join's early return and emitted nothing at all, so the same key reached the clipboard on one cursor and not on two. Nothing could READ the clipboard: the SDL shell had no SDL_GetClipboardText anywhere in it, and the tty shell never asked for OSC 52, so `p` from another application was dead in both. Now it is helix's split. y/d/c/p/P/R and the acme chords are the DEFAULT REGISTER and nothing else; the system clipboard is five words on helix's own letters -- SPC y, SPC Y, SPC p, SPC P, SPC R -- spelled as builtins so they land in Help and are executable like every other verb. The one exception is the tag `y` chord, which still mirrors out because a tag is always insert, so SPC cannot be pressed there, and copying the path out is the whole point of the chord. Reading is a new read_clipboard effect answered by an ordinary Event.paste, so the round trip is honest about being one: SDL and NSPasteboard answer inside the same drain, the browser answers a promise, and a terminal answers over OSC 52 or -- far more often -- refuses. A refused read is a paste that does not happen, and the request dies at the next keystroke rather than landing minutes late in whatever pane is focused by then. The tty shell also enables BRACKETED PASTE now and coalesces paste_start..paste_end into one event. Before this a paste arrived as a flood of individual key presses: plausible in insert mode, and in normal mode every pasted character ran as a command. n/N. They stepped the armed results buffer and immediately Looked each row, so you could not walk past a hit without opening it. They are a MOTION now: select the next look-able text, open nothing, and let Enter decide. What they step is the largest whitespace-delimited run look.resolve can act on (look.lookableSpan, wrapper punctuation peeled), over a RING of panes -- every pane that has performed a look, most recent first, then the output buffers that have not, newest first, and only if both are empty the pane in front of you. N is the exact inverse of n, computed rather than remembered: both directions ask the same question about the same spans and compare against the column the walk parks on, so x presses one way and x back land exactly where you started, pane boundaries and the ring's seam included. A ring rather than a list with two ends because a shell's cursor sits at the prompt, below everything it has printed, so a walk that could not come round would have nowhere to go on the very first press -- which is the case n/N were written for. One motion everywhere, no pane-kind or buffer-kind special case. The only thing a buffer may change is the GRAIN of what a step selects, and it does it with one flag rather than a branch: output_pane.Traits.commands (renamed from `executes`, which named one reader's behaviour rather than the fact) makes a row select WHOLE, because a ThemeSel line is a word to run and has no path inside it to pick out. `]d`/`[d` are not n/N -- they are helix's diagnostic motions, their job is to ARRIVE, and they still reach searchStep. THE TTY PROMPT. Leaving raw tty blanked the prompt row, and the command you had typed at that prompt shares the row, so it went too -- a shell out of tty read as output only. OSC 133 marks the row CELL by cell, so the two are separable: config.tty_blank = .prompt cuts the prompt's own columns and leaves the command, left-hugged at column 0 in line with the output under it rather than in a bay of blanks. .prompt_and_input is the old behaviour, kept. Because the row is now something you can put a cursor in, enterTty adds the hidden prompt width back before asking ghostty to walk the shell's own cursor to it -- the modal column on a cut row is short by exactly that much. Verified: unit-test 186/186 (nine new), snap 87/87 (new ttyprompt.snap), hxdiff 481 and hxparity 561 with 0 mismatches, tty and gui both build. And against the real binaries rather than the harness: in a pty, SPC y emits OSC 52 carrying exactly the selection while plain y emits nothing, SPC p issues the read and pastes the reply, and a bracketed paste of "dd..." inserts text instead of deleting two lines. In a real SDL window, SPC y then SPC p round trips through the system clipboard while the default register holds different text. Setting tty_blank back to .prompt_and_input reproduces all 86 old goldens byte for byte.
* fix tab rendering - waybe wrecklessGabriel Schneider2026-08-10
|
* Esc alternates between the last two panes; Toggleterm is goneGabriel Schneider2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc ran Toggleterm, which hopped between the newest DOC and the newest TERMINAL. That distinction never earned its keep. It made Esc unpredictable — which of three panes you landed on depended on their kinds, not on where you had been — and it could not alternate between two files at all, which is the case you hit most. Editing two files, Esc did nothing. The replacement already existed. Last (SPC j j) is "the pane you were in before this one, whichever it was": it walks the jump stack for the newest entry naming a different pane and restores its line and column. So Esc, and Shift-Esc in tty, now run Last, and Toggleterm is deleted rather than renamed — a third implementation of "go to the other pane" was the thing to avoid. SPC w t goes with it; the w group is the four directional moves, and the jump group already had SPC j j. Held down, Esc alternates. Two files, a file and its shell, a file and a +Search — all the same, because Last has no notion of kind to get wrong. This depends on the swap in the same series: Last reads the stack backwards, and until hopping stopped appending, the pane you came from could fall off it. windownav.snap needed only its keys and prose changed — its golden did not move at all, which is the useful evidence here: for the one scenario the old builtin handled well, Last produces an identical focus sequence. Coverage for what it did not handle is new: a unit test opens a second FILE by looking its name and asserts Esc alternates between two panes of the SAME kind, which is the case that used to be a no-op. Docs follow: tutor.txt, docs/helix-keys.md, docs/design.typ, and the builtin index goldens, which are now one row shorter. 75/75 snapshots, both unit suites, and the macOS ABI build all pass.
* prioritize useful topbar commands and put Kill lastGabriel Schneider2026-08-10
|
* add New builtin for an empty temporary file in the calling columnGabriel Schneider2026-08-10
|
* normal Esc toggles document/terminal focusGabriel Schneider2026-08-10
|
* multiple cursors, regex selection, and Ctrl-c commentsGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and sels[] holds helix's OTHER ranges. That split is why nothing moved at one cursor: with nsel == 0 not one line of the existing motion, operator, render or mouse code takes a different branch, which is what protects 800 differential cases and 67 goldens. paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's Selection::new (min width 1, sorted, overlaps merged, primary follows its range through a merge). An ordinary key runs the single-selection handler once per range, visited last-first so an edit never disturbs a range still waiting, and each finished pass is remembered as a distance from the END of the text, which an earlier edit cannot move — helix's change mapping without a change map. pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from the primary and stops the replay, which also closes the use-after-free window if it frees the pane. s and S reuse the / prompt wholesale rather than growing a second one: the pattern is typed into the tag tail, and every keystroke re-runs the match from the selection the prompt opened on, so the preview is live and Esc is just the empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size struct with no allocator — with 64 ops and 8 char classes per pattern, no case-insensitive flag (helix's smart case is done by folding a scratch copy), no captures, no multi-line anchors. The last two are the two waivers. Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides comment-vs-uncomment ONCE for the whole selection; replaying it would take that decision n times. Comment tokens are a table in config.zig keyed on the same extension syntax.zig picks grammars by. Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left the cursor one cell before where the append began. helix's restore_cursor can never walk past the origin; ours backed up unconditionally. hxdiff was green before AND after — the old one-selection contract could not see it. hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the harness contract now reports every range and its primary, omitted when there is one, so 359 of the 360 old goldens are byte-identical. The one that moved is o-count: helix's 2o really does leave two cursors and could not say so before.
* themes: one file each, generated from helix and zed, picked by stepping a listGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | Each theme is a .zig file of pure data and nothing enumerates them by hand — fold() walks the container's declarations, so a theme is a file and that is the whole registration. Field by field rather than a wholesale coercion, which makes a missing field a compile error that names it. tools/gen_themes.zig reads helix .toml and zed .json out of vendor/themes and emits one .zig each; build.zig reads that directory, so adding a theme is dropping a file in. Output goes to the build cache rather than the tree, so zig owns the freshness check and a deleted source cannot leave a stale theme behind. Vendored, not read from the genizah: the build stays offline. A source it cannot map is a hard error naming the file and the key, never a silently black-on-black theme. ThemeSel is the clever half. Traits gained an `executes` column, so n/N over that buffer hands the whole line to Exec instead of the leading word to Look — both arms the ordinary builtin, so a stepped row does exactly what the matching mouse button on it would. Stepping the list previews each theme live. The trait is a pane property, not an output-pane branch, so any pane whose lines are commands can opt in. Goldens: leader gains SPC t t; theme's fourth NextColor no longer wraps to helix because the ring is fifteen long. New: themesel.
* Builtins (Find, Grep) qol improvements and ui fixesGabriel Schneider2026-08-01
|
* k off the topmost tagline focuses the TOPBAR: row 0 is a tagline tooGabriel Schneider2026-08-01
|
* tag columns are the WHOLE rendered tag: h/j/k/l walk taglines, the path ↵Gabriel Schneider2026-08-01
| | | | selects but never edits
* trim the taglines: image renderer toggles and Colors/Crt become leader-onlyGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | The image tag spelled out `Petscii <C64|Term> Ascii` and actOnSelection matched those words by hand before the builtin dispatch. They are three real Builtins now (SPC t p/l/a), so the by-word matching is gone, the tag is a plain `img <path> Del`, and with nothing live left in it the image early-returns in curTail, enterTagEdit and the tag-click guard go too — an image tag edits like every other pane's. Colors and Crt leave the topbar for SPC t c / SPC t r: set-once display switches should not spend width in a bar you read every frame. NextColor stays, it is the one you cycle repeatedly. Both remain full builtins. The snap scripts that clicked those words now press the leader instead, and images.snap finally exercises the palette and ascii toggles for real: its old clicks at x=57 landed on the neighbouring shell's tag, so that toggle had never been covered. The test PPM grew to 512x512 with LCG noise so the ASCII bitmaps actually win cells and dropping them visibly changes the art.
* window navigation builtins: SPC w h/j/k/l focus, SPC w t file<->terminalGabriel Schneider2026-08-01
| | | | | | | | | | | | Left/Down/Up/Right run the existing focusDir (now taking the pane the builtin ran on, so an executed name acts on ITS pane); the Ctrl-w prefix routes through the same four builtins instead of calling focusDir itself. Toggleterm hops between the most recently focused document and terminal off focus_hist, the MRU the core already rebuilds each sync. Ctrl-w stays: a tty-mode pane never sees SPC, so it is the only keyboard way out of one. Its snapshot step now moves focus somewhere (c-w l), which the old c-w h in that layout did not.
* Find builtin (SPC f f): fd in-core, results into +Search, n/N opens each hitGabriel Schneider2026-08-01
| | | | Save moves w -> f s to free the w group for window navigation.
* adding better search to tty modeGabriel Schneider2026-08-01
|
* steam deck nice versionGabriel Schneider2026-08-01
|
* helix diff testing and feature parityGabriel Schneider2026-08-01