Skip to content

Instantly share code, notes, and snippets.

@xpe
Last active May 15, 2026 20:37
Show Gist options
  • Select an option

  • Save xpe/7270e03eef36efd6422c15632c659a66 to your computer and use it in GitHub Desktop.

Select an option

Save xpe/7270e03eef36efd6422c15632c659a66 to your computer and use it in GitHub Desktop.

ME: Ok, let's simply diagnose the PROBLEM (with certainty) in jjui ... by reading the code. Don't say what the solution is yet.


Claude Opus 4.7 (running in the jjui repository)

Diagnosis with certainty, from reading the code:

1. The cursor in the inline description editor is a software-painted, blinking cell, not the terminal's hardware cursor.

internal/ui/operations/describe/describe.go:33 declares input textarea.Model (from charm.land/bubbles/v2/textarea). The textarea component uses bubbles/v2/cursor internally — confirmed by line 81 handling cursor.BlinkMsg. That component renders the cursor by styling one cell in its View() string and toggling visibility on a timer. The styled output is then drawn into jjui's display via dl.AddDraw(rect, input.View(), 0) at line 150. Nothing about this involves the terminal's hardware cursor.

2. jjui never positions the terminal's hardware cursor.

Searched the entire repo: no tea.NewCursor, no assignment to v.Cursor on the tea.View returned from View(). The only Cursor() methods present (revisions.go:121, oplog/operation_log.go:55, evolog_operation.go:68) return int row indices for selection, not *tea.Cursor. The tea.View constructed in internal/ui/ui.go:755-769 sets AltScreen, ReportFocus, and MouseMode, but leaves Cursor unset — so Bubble Tea hides the hardware cursor.

3. jjui does receive focus events but does nothing with blur, and on focus it only refreshes data.

internal/ui/ui.go:764 enables v.ReportFocus = true. internal/ui/ui.go:127-131 handles tea.FocusMsg by issuing RefreshAndKeepSelections. There is no tea.BlurMsg case anywhere in the codebase.

The certain explanation of the bug:

When you Cmd-Tab away, the terminal stops drawing the application's output buffer in the foreground and there is no hardware cursor to fall back on (jjui hid it implicitly by never positioning one). The only cursor was the software-painted cell inside the textarea's View() string, which only appears as part of the rendered frame — and the terminal isn't actively redrawing that frame while the window is in the background or at the moment of focus loss. So the cursor visually disappears.

When you Cmd-Tab back, tea.FocusMsg fires and triggers RefreshAndKeepSelections, which refreshes data — but the description editor's cached frame (w.cachedFrame at ui.go:756) is reused unless w.render is true, and the textarea's blink state may now be in the "off" half of its cycle. Either way, no code path forces an immediate re-render with the cursor cell on. The cursor returns only when the next blink tick fires or the next keystroke triggers a re-render.

ME: Is the above treatment of the cursor considered incorrect? What does gh-dash do?


Claude Opus 4.7 (running in the jjui repository)

Not incorrect by Bubble Tea convention — jjui is doing the standard thing the framework's components encourage. gh-dash does exactly the same.

Concretely, in gh-dash/internal/tui/components/search/search.go:30-49, the search box uses bubbles/v2/textinput with explicit cursor styling:

Cursor: textinput.CursorStyle{
    Color: ctx.Theme.FaintText,
    Shape: tea.CursorBar,
    Blink: true,
},

That Shape/Blink config is consumed by the bubbles cursor component, which paints a styled cell into the rendered string and toggles it on a blink timer — same software-cursor mechanism jjui's textarea uses. And just like jjui, gh-dash never positions the terminal's hardware cursor anywhere (no v.Cursor or tea.NewCursor in the codebase outside test fixtures).

So the verdict:

  • By Bubble Tea convention: jjui is correct, gh-dash is correct, this is the idiomatic pattern. Both ship the same bug under the same conditions, which your empirical test already confirmed.
  • By broader terminal-app norms (Helix, vim, less, zsh, every shell): it's incorrect, because the right behavior on focus loss requires a hardware cursor for the terminal to render its hollow-outline state — and no software-painted cell can produce that.

So the bug isn't a jjui mistake against framework norms; it's a gap in the framework's idiomatic pattern that no widely-used Bubble Tea app (including Charm's own ecosystem) has filled.

ME: Show me where, if anywhere, Bubble Tea suggests doing the cursor that way. Show me the docs.


Claude Opus 4.7 (running in the jjui repository)

The docs actually say the opposite of what you might expect. The hardware cursor is the documented native primitive; the software "virtual" cursor is an internal helper for the textinput/textarea components.

Bubble Tea v2 documents tea.View.Cursor as the way to show a cursor.

charm.land/bubbletea/v2/tea.go:128-131:

// Cursor represents the cursor position, style, and visibility on the
// screen. When not nil, the cursor will be shown at the specified
// position.
Cursor *Cursor

The Cursor struct (tea.go:357-370) carries Position, Color, Shape (CursorBlock / CursorUnderline / CursorBar), and Blink. There's a tea.NewCursor(x, y) constructor. This is the terminal's hardware cursor, set on the View your View() method returns.

The v2 upgrade guide explicitly directs people to it.

UPGRADE_GUIDE_V2.md lists Cursor in its tea.View features table as "Control cursor position, shape, color, and blink", and the migration table says:

tea.HideCursor   →  view.Cursor = nil
tea.ShowCursor   →  view.Cursor = &tea.Cursor{...} or tea.NewCursor(x, y)

So in v2, cursor control is documented as a tea.View field, not as a software-rendered cell.

The bubbles cursor package documents itself as "virtual" — not the recommended primary mechanism.

charm.land/bubbles/v2/cursor/cursor.go:1-3:

// Package cursor provides a virtual cursor to support the textinput and
// textarea elements.
package cursor

The word "virtual" is explicit and load-bearing. The package exists to support textinput and textarea — it's a styling helper for editable text components, not a general recommendation for how Bubble Tea apps should show a caret.

There is no Bubble Tea documentation telling you to prefer the virtual cursor over the hardware cursor. I searched the v2 README and the upgrade guide; no such guidance exists. What happens in practice is that apps reach for bubbles/textinput or bubbles/textarea because those are the obvious building blocks for editable text, and those components paint a virtual cursor by default — without coordinating with view.Cursor. That's the fork in the road where idiomatic-component-use and idiomatic-cursor-control diverge, and the bubbles components silently win.

So: jjui (and gh-dash) follow what the bubbles components do, not what the Bubble Tea framework documents. The framework's documented native mechanism would actually behave correctly on focus loss, because it positions the terminal's hardware cursor — exactly the thing the terminal can render as a hollow outline when unfocused.

ME: Explain 'hardware cursor' please.


Claude Opus 4.7 (running in the jjui repository)

"Hardware cursor" is a slight misnomer carried over from older graphics terminology, but in terminal-app discussions it has a specific meaning. Let me unpack it.

What it actually is.

Every terminal emulator (Ghostty, iTerm2, Kitty, the macOS Terminal, xterm) maintains an internal cursor: a row/column position plus a visibility flag plus a shape (block, underline, bar) plus a blink state. This cursor lives inside the terminal emulator process, not inside your program. The terminal draws it on top of whatever character cell it's currently positioned over, every frame, on its own — your program doesn't have to do anything per-frame to keep it visible.

It's called "hardware" cursor by analogy to old GPU-drawn sprite cursors, even though in a modern terminal emulator it's just software in the emulator process. The contrast it draws is "the cursor the terminal manages" vs. "a cursor my application paints into a character cell."

How a program controls it.

Through ANSI escape sequences sent to stdout. The relevant ones:

  • \x1b[<row>;<col>H — move it to a position
  • \x1b[?25h / \x1b[?25l — show it / hide it
  • \x1b[<n> q — set its shape (1/2 block, 3/4 underline, 5/6 bar; even numbers steady, odd blinking)
  • \x1b]12;<color>\x07 — set its color (OSC 12)

That's the whole API. When Bubble Tea v2's view.Cursor field is non-nil, the framework emits these escapes for you with the position/shape/color/blink you specified. When you write a shell prompt, the shell positions this cursor and the terminal handles everything else.

Why it matters for the focus-loss bug.

Because the terminal emulator owns the cursor, the terminal emulator gets to decide what to do with it when the window loses focus. Every modern terminal independently renders an unfocused window's cursor as a hollow outline — Ghostty does it, iTerm2 does it, Kitty does it. This isn't a feature programs opt into; it's just what terminals do with the one cursor they're managing. If you've ever Cmd-Tabbed away from a shell prompt and seen the block cursor become a hollow rectangle, that's the terminal doing it on its own.

Why a "software" cursor doesn't get this for free.

The bubbles cursor component paints a styled cell — say, a reverse-video character — directly into the string your View() function returns. From the terminal emulator's perspective, that's just text. There's no cursor there as far as the terminal is concerned. So when you Cmd-Tab away, the terminal has no cursor to outline, and even if your app keeps the styled cell drawn, the terminal may not be redrawing the application's frame at all while it's in the background. The visual result: cursor vanishes.

The taxonomy in one line.

  • Hardware cursor = the terminal emulator's own cursor, controlled by escape sequences, gets focus/unfocus rendering for free.
  • Software/virtual cursor = a styled character cell your program paints into its output buffer, indistinguishable from text to the terminal.

Bubble Tea v2's view.Cursor is the first; bubbles' cursor package is the second, and explicitly labels itself "virtual."

ME: Why doesn't Bubble Tea just use the hardware cursor always? Why both?


Claude Opus 4.7 (running in the jjui repository)

Real reasons both exist — they're not redundant.

The hardware cursor's hard limits:

  1. There's exactly one per terminal. If your UI has multiple text inputs visible at once (a search box at the top, a comment field in the sidebar, an inline editor in a list), only one can show the hardware cursor. The others need some visual indicator of where their caret is, and that has to be painted into their output string.

  2. It sits on a cell; it can't compose with cell styling. If you have a selection highlighted in inverse video and the cursor is inside the selection, you want the cursor cell to invert the selection color, not just sit on top of a generic block. The terminal's hardware cursor is a fixed style applied uniformly; it doesn't know about your selection. To render "cursor inside selection" correctly you have to paint the cell yourself.

  3. Three shapes total: block, underline, bar. That's the entire vocabulary. If you want a colored bar that animates, a thick half-block, a custom color tied to your theme, or anything else expressive, the terminal can't render it.

  4. It needs absolute screen coordinates at render time. A bubbles textarea rendered into a string can be placed anywhere on screen by its parent — embedded in a scrollable viewport, inside a list cell, indented inside a panel. The textarea doesn't know its final (row, col) when it generates the string. A software cursor goes wherever the string lands automatically; a hardware cursor requires the parent to compute and pass back absolute coordinates, which breaks the clean string-composition model bubbles is built on.

  5. Composability of components. Bubbles components produce strings. Strings concatenate. The model is: every component renders independently, the parent assembles. There's no "give me your cursor position so I can forward it up the tree to the root View()" channel — and adding one would invert the data flow the framework is built around.

  6. Testability. Snapshot tests check the output string. A software cursor is in the string, so it's tested. A hardware cursor is an escape-sequence side-effect on the terminal — invisible to a string snapshot. For a component library that ships with tests, painting into the string is the testable choice.

  7. Animations and demos. Charm publishes a lot of demo content — animated cursors in marketing GIFs, tutorial walkthroughs, presentations made with slides. Full pixel-by-cell control is essential there, and a hardware cursor restricted to three shapes can't do it.

What the hardware cursor uniquely gives you:

  1. Focus/unfocus rendering for free (the bug at hand).
  2. The terminal-native blink at the user's configured rate, synchronized with their other terminals.
  3. System cursor color preference if the user has one configured in the terminal emulator.
  4. Accessibility tooling (screen-readers, magnifiers) that hooks into the terminal's cursor APIs sees a real caret position; a painted character cell is invisible to them.

Why offering both is the right design.

Hardware-only would break composability, multi-input layouts, styled selections, animations, and snapshot tests. Software-only loses focus/unfocus rendering, blink synchronization, accessibility, and theme integration.

The ideal pattern is what Helix does and what no Bubble Tea app currently does: paint a software cursor when your input has focus and the terminal has focus (full control, full styling), but set view.Cursor to a hardware cursor at the same position when the terminal loses focus (so the terminal can render the hollow outline). The bubbles components could do this internally — they have the position and they receive tea.FocusMsg/tea.BlurMsg since v2 — but they don't yet. Adding that coordination is the actual fix, both for jjui's bug and for the same bug in every Bubble Tea v2 app using bubbles' editable text.

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