Skip to content

Feat(tui): a bar cursor in insert mode, an underline in replace mode - #29

Merged
adibhanna merged 12 commits into
ZenNotes:zn-0.14.0from
spirit-of-light:feat/insert-bar-cursor
Nov 1, 2026
Merged

adibhanna merged 12 commits into
ZenNotes:zn-0.14.0from
spirit-of-light:feat/insert-bar-cursor

Conversation

@spirit-of-light

Copy link
Copy Markdown
Contributor

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 r waits 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 keeps term.File's methods, so window size and raw mode detection still work.

Where the bar goes (cursor.go):

  • Notes: the insertion point in any pane layout (splits, sidebar, side panel, zen, split note mode), after resizes, over tabs and wide runes, clamped to the editor's drawn width when word wrap is off. The cell comes from the focused pane's own wrapping, so the same note open in two unequal panes still lands right.
  • Command line: :, / and ?, following Left and Ctrl+B.
  • Text fields: the palette, pickers, prompts including the masked one, the ex prompt, database cell and date editing, task prompts, and quick capture (which follows the note rules), also under a title that wraps.
  • Hidden: while a menu, the hints overlay, which-key or a toast is showing, and while focus is on the sidebar or a side panel. The tag menu keeps the bar.

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 while r waits.

Tests, most written red first: the wrapper's bytes (cursorout_test.go), ReplacePending, and app-level tests that drive keys, call View() 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 --check and bash scripts/sandbox.sh --smoke pass. 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

spirit-of-light and others added 12 commits October 31, 2026 21:44
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
adibhanna changed the base branch from main to zn-0.14.0 November 1, 2026 20:03
@adibhanna
adibhanna merged commit 87a7838 into ZenNotes:zn-0.14.0 Nov 1, 2026
5 checks passed
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.
@adibhanna

Copy link
Copy Markdown
Contributor

Thanks, the bar makes insert mode feel right, and the tests are excellent. It's merged into zn-0.14.0 and ships in zn 0.14.0. The stdout wrapper changed a few things outside the editor, fixed in six follow-ups:

  • 2dbedd7: the external editor (Space l e, :editor, opening config.toml) got a pipe instead of the terminal as stdout. Vim warned "Output is not to a terminal" and Neovim drew a blank screen. It writes to the terminal again.
  • 015a97e: with stdin redirected (zn tui < /dev/null, or a launcher that passes a pipe), Bubble Tea no longer opened the terminal itself, so keys never reached zn. It reads the terminal again.
  • e728db0: Ctrl+Z on Windows released the wrapper for good, because Bubble Tea can't suspend there and no resume follows. It now leaves the cursor alone on Windows.
  • a56296c: a toast hid the terminal cursor for four seconds even where it doesn't cover it, so the insertion point vanished while typing. It now hides it only where the toast is drawn over it.
  • 038ea1f: removes the drawn-cursor helpers nothing calls any more.
  • e6bce16: the README says how the cursor looks while you type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants