Repository navigation
Feat(tui): a bar cursor in insert mode, an underline in replace mode - #29
Merged
adibhanna merged 12 commits intoNov 1, 2026
Merged
Conversation
zn draws its own cursor as a reverse-video cell because Bubble Tea v1 hides the terminal cursor and parks it at the bottom after every frame. A bar or underline cursor in insert and replace mode needs the real terminal cursor, so cursorOut sits between the program and the terminal: it appends the wanted position and shape (steady bar, CSI 6 SP q, or steady underline, CSI 4 SP q) to every frame, writes it on its own when only the cursor moved, since Bubble Tea skips unchanged frames, and resets the terminal to its default shape on request. A write that is a lone control sequence gets nothing appended, and while released (for suspend or an external program) the wrapper writes no cursor bytes at all. It keeps the method set of Bubble Tea's term.File so window-size and raw mode detection still work, and uses only charmbracelet/x/ansi, which is already a dependency. Nothing uses it yet; later steps wire it in. Tests, written red first: steady shapes, the reset sequence, frame and lone-sequence writes, set-on-change, release, concurrent writes under -race, and the forwarded file descriptor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After `r`, with or without a count or register, the editor stays in normal mode until the replacement character arrives, so a host cannot tell from the mode that a character is pending. ReplacePending answers that: it is true only in normal mode while the pending keys are an optional register and count followed by `r`, and false once the character or Esc arrives, in visual mode and in insert mode. The TUI uses it to show an underline cursor while it waits. Tests: `r`, `3r` and `"ar` are pending; `rx` replaces and clears it; `r<Esc>` clears it with the text unchanged; `vr`, insert mode, a typed `r` in insert mode and another pending operator are not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a note is edited, the reverse-video cell that stands in for the cursor hides the character under it and gives no sign of the mode. The TUI now works out, for every frame, the cursor the terminal should show: a steady bar in insert mode or with vim mode off, a steady underline in replace mode, and nothing in normal, visual and command-line mode or while the note is loading, where the drawn block stays exactly as it was. The cell is found the way the tag menu finds its anchor: the pane origin, the gutter, then the width of the text before the cursor on its wrapped row, so tabs and wide runes land on the right cell. While the terminal cursor owns the insertion point the editor stops drawing the block there and past the end of the line. Nothing reaches the terminal yet; a later commit connects the frame's cursor to the output wrapper. Tests: the bar on a plain, wrapped, CJK, tab and end-of-line position with no reverse cell drawn; the underline in replace mode; the bar with vim mode off; the block in normal and visual mode and after Esc; and the block while the note loads, with vim mode on and off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cursor follows typing, arrow keys, backspace and mouse clicks, and lands on the character under it in a single pane, either split, with the sidebar or a side panel open, with tabs hidden, in zen mode and in split note mode, and again after a resize rewraps the line. A buffer's wrapped rows are shared by every pane that draws it, and the last pane drawn wins. With one note open in two panes of different widths, a focused pane drawn first would have placed the cursor using the other pane's wrapping, on a blank cell. The buffer now keeps a copy of the rows taken when the focused pane draws it, and the cursor reads that copy. Tests: the cursor through editing and mouse moves, across the layouts above, in the second pane of each split, after a resize, and with the same wrapping note in two unequal panes with either pane focused. The normal and visual mode block is compared byte for byte with the cursor style, so a change to how it looks fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nvim shows an underline while `r` waits for the replacement character, which tells the user the next key replaces rather than moves. zn now does the same: while the editor reports a pending `r`, the terminal cursor is an underline on the character to be replaced, and once the character or Esc arrives the drawn block comes back. In visual mode `r` keeps the block. Tests: `r` and `3r` show the underline on the cursor character with no reverse cell; `rx`, `r<Esc>` and `2rx` end with one block and no terminal cursor; `v` then `r` keeps the block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `:`, `/` and `?` lines drew the same reverse-video cell as the editor. They now show the bar after the typed text, following Left and Ctrl+B (start of line), over wide runes too, and the wildmenu after the text does not move it. When the line closes with Enter or Esc, the buffer's own cursor rules apply again. Tests: the bar after empty and typed text for each of `:`, `/` and `?` with no reverse cell on the line; after Left once, twice over a wide rune, and after Ctrl+B; and the buffer's block after Esc and Enter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drawn block used to be clipped where it did not fit, so nobody saw it. The terminal cursor is not clipped: with word wrap off, a long line put the bar on the pane frame, a neighbouring split, the side panel or the preview half in split note mode, and a full wrapped row put the end-of-line bar one cell past the text. A command line longer than the terminal sent the cursor off screen, where the terminal pins it to the last column over other text. The focused pane now records the editor's drawn width next to its rows, and the cursor is clamped to the last cell of that width rather than hidden, so the bar at the end of a full row stays visible. The command line's cursor is clamped to the last column. Tests, written red first: word wrap off in a single pane, a vertical split, split note mode and with the side panel open; a full row with wrap on in a single pane and in split note mode; and a command line wider than the terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anywhere text is typed outside a note, the field drew its own reverse-video cell. The palette, the pickers, prompts including the masked password prompt, the ex prompt, database cell and date editing, and the task due and field prompts now report their cursor cell, and the terminal shows the bar there: after the typed text, after the last dot in a masked prompt, at the start of an empty field with a placeholder, and clamped inside the box for long input. A field opened over a note being edited takes the bar; closing it hands the cursor back to the note. Quick capture is a small vim editor, so it follows the note rules: bar in insert mode or with vim mode off, underline in replace mode, block in normal mode. Its title can wrap, so its height is measured. Tests: each field above, a placeholder in an empty palette and picker, a long input, a prompt over a note in insert mode, Esc back to a note in normal mode, and quick capture in insert mode and with vim mode off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bar blinking through a menu or a toast looks like the menu takes text. While a context menu, a centred menu, the hints overlay, the which-key popup or a toast is showing, the terminal cursor is hidden, and it comes back on the same cell when it closes or expires. A pop-up with its own text field still shows the bar in that field, and the tag menu keeps the bar at the insertion point, since the user is still typing. With focus on the sidebar or a side panel the cursor is hidden until focus returns to the pane. Tests, with vim mode on and off: each covering element hides the cursor and restores it on the same cell; the tag menu keeps it; the sidebar and outline panel hide it and focusing the pane brings it back; a prompt opened from the sidebar shows the bar in the prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The program now writes through the cursor output wrapper, and every frame hands it the cursor worked out for that frame, so the bar and underline appear in the terminal. zn cannot read the shape the terminal used before it started, so on the way out it resets the terminal to its default shape: after a normal quit, after an error and after a panic Bubble Tea recovers. zn has no recover of its own, so the reset runs after the program returns, on every path. There is no setting; a default configuration gets the bar. Tests: the output after `:q`, after the context is cancelled and after a panic ends with the default-shape reset following the last bar, and an empty config.toml gives the bar in insert mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shell or editor started from zn should see the terminal's own cursor, not zn's bar. Before Ctrl+Z, `:suspend`, the external editor and opening the config file, zn resets the cursor to the terminal default and stops writing cursor sequences. On resume, or when the external program returns, it takes the cursor back and writes the current one, which the next frame corrects if the mode changed. Tests: Ctrl+Z and `:suspend` write the reset and release the wrapper; resuming writes the bar at the insertion point; the external editor and config actions write the reset; returning in insert mode shows the bar and in normal mode hides it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prompt, the palette and the picker assumed their title fits on one row and put the field's cursor on the second content row. A title wider than the box wraps, as the sidebar's "New note in <folder>" does for a deep folder on an 80-column terminal, and the bar then sat on the title's second line, one row above the typed text. They now measure the rendered title height, as quick capture already did. The restore tests now show the bar before the panic or the cancelled context, so they prove the reset follows a shown shape, and the default-config test builds the app the way zn does, from an empty config.toml. Tests, written red first: a prompt, palette and picker with a title wider than the field put the bar right after the last typed rune. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adibhanna
added a commit
that referenced
this pull request
Nov 1, 2026
…(by @spirit-of-light) With the review follow-ups on top of the contributor's commits.
Contributor
|
Thanks, the bar makes insert mode feel right, and the tests are excellent. It's merged into
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The editor drew its cursor as a reverse-video cell in every mode, so insert mode looked the same as normal mode and the cell hid the character under it. zn now shows the real terminal cursor while text is being typed: a steady bar in insert mode and with Vim mode off, a steady underline in replace mode and while
rwaits for its character. Normal and visual mode keep the drawn block exactly as before. There is no setting and no new dependency.Bubble Tea v1 hides the terminal cursor and parks it at the bottom after every frame, so the program now writes through a small wrapper around stdout (
cursorout.go). After each frame it moves the cursor to the cell worked out for that frame and sets its shape with DECSCUSR (CSI 6 SP q/CSI 4 SP q). When only the cursor moved it writes that on its own, because Bubble Tea skips unchanged frames. It keepsterm.File's methods, so window size and raw mode detection still work.Where the bar goes (
cursor.go)::,/and?, following Left and Ctrl+B.zn cannot read the shape the terminal had before it started, so it resets to the terminal default (
CSI 0 SP q) on quit, on error and after a panic Bubble Tea recovers. It also resets before Ctrl+Z,:suspend, the external editor and opening the config, and takes the cursor back on return. The vim engine gets one small query,ReplacePending, because the editor stays in normal mode whilerwaits.Tests, most written red first: the wrapper's bytes (
cursorout_test.go),ReplacePending, and app-level tests that drive keys, callView()and check that the bar sits on the cell where the character is drawn with no reverse cell left (cursor_*_test.go). These cover each layout, mode, field and covering element, the restore paths on a fake terminal, and suspend and exec.go test -race ./...,go vet ./...,go build ./cmd/zn,gofmt -l,git diff --checkandbash scripts/sandbox.sh --smokepass. Checked by hand in kitty: insert, replace,r, Vim mode off, the command lines, Search notes, a database date cell, a toast, the tag menu, sidebar focus, a split, zen mode, a resize, Ctrl+Z /fg, the external editor and:q, after which the shell's cursor is back to the default shape. Not tested under tmux.🤖 Generated with Claude Code