tui: real terminal cursor in composer + window title (v2)

Two v2-unlocked polish items now that the TUI is on bubbletea v2:

Native cursor — the composer caret was a frame-animated "▏" that forced the
redraw loop to keep ticking in insert mode. v2's View.Cursor lets the terminal
draw and blink a real cursor, so:
- editorLines/launcherInput drop a width-1 marker rune (U+E123, same cell width
  as "▏") at the caret; View() locates it in the fully-composited frame via
  splitCursor → (X,Y), swaps it for a space, and sets a blinking CursorBar there.
- render() (preview/golden tests) resolves the marker to the static "▏" glyph, so
  screenshots are unchanged and the marker never leaks into output.
- nil cursor in normal mode hides it (vim-correct); gated on overlay==None so an
  open palette/files modal (which keeps editMode==Insert) doesn't draw a cursor
  behind it.
- animating() no longer forces a redraw in insert mode — the terminal blinks the
  cursor itself, so an idle insert screen holds still (native text selection
  survives) and burns no frames.

Window title — View.WindowTitle names the terminal tab after the active session,
falling back to the model name then "correx".

Tests: new cursor_test.go (coord extraction, mode/overlay gating, no marker leak);
updated the animating-gate test for the new insert-mode contract. Build, vet,
gofmt, full suite green; compose preview shows the static caret with no marker leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:28:22 +00:00
parent d247b19608
commit 85af2c67b7
4 changed files with 157 additions and 11 deletions
+58 -8
View File
@@ -30,15 +30,63 @@ const (
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// cursorMarker is a private-use rune the composer drops into the caret cell so
// View() can locate it in the fully-composited frame and place a real terminal
// cursor there (see splitCursor). It measures one display column — same as the
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
// a live cursor or the static glyph.
const cursorMarker = ""
// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so
// render() builds the string and View() wraps it with the screen settings.
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
// and View() resolves the caret to a real blinking cursor + sets the window title.
func (m Model) View() tea.View {
return tea.View{Content: m.render(), AltScreen: true}
content, cur := splitCursor(m.renderRaw(), " ")
return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
}
// render builds the frame string.
// render returns the frame as a plain string with the caret drawn as the static
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
func (m Model) render() string {
content, _ := splitCursor(m.renderRaw(), "▏")
return content
}
// splitCursor finds the caret marker in a composited frame, replaces it with repl
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
// screen position — nil when no marker is present (e.g. vim normal mode), which
// tells bubbletea to hide the cursor.
func splitCursor(raw, repl string) (string, *tea.Cursor) {
idx := strings.Index(raw, cursorMarker)
if idx < 0 {
return raw, nil
}
pre := raw[:idx]
y := strings.Count(pre, "\n")
lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
x := ansi.StringWidth(raw[lineStart:idx])
clean := strings.Replace(raw, cursorMarker, repl, 1)
cur := tea.NewCursor(x, y)
cur.Shape = tea.CursorBar
return clean, cur
}
// windowTitle names the terminal window/tab after the active session, falling
// back to the model name and then the bare program name.
func (m Model) windowTitle() string {
if m.displayState() == StateInSession {
if s := m.session(m.selectedID); s != nil && s.Name != "" {
return "correx — " + s.Name
}
}
if m.currentModel != "" {
return "correx — " + m.currentModel
}
return "correx"
}
// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
func (m Model) renderRaw() string {
if m.quitting {
return ""
}
@@ -403,8 +451,10 @@ const maxInputLines = 6
func (m Model) editorLines() []string {
t := m.theme
caret := ""
if m.editMode == ModeInsert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
// Only the focused composer (no modal over it) owns the live cursor; an open
// overlay keeps editMode==Insert but draws its own filter caret instead.
if m.editMode == ModeInsert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
buf := m.inputBuffer
cur := m.inputCursor
@@ -551,8 +601,8 @@ func (m Model) renderInput() string {
insert := m.editMode == ModeInsert
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
if insert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
var content []string
switch {