diff --git a/apps/tui-go/internal/app/cursor_test.go b/apps/tui-go/internal/app/cursor_test.go new file mode 100644 index 00000000..a32c4232 --- /dev/null +++ b/apps/tui-go/internal/app/cursor_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "strings" + "testing" +) + +// splitCursor must report the caret's display column/row from a composited frame +// (ANSI ignored) and swap the marker for the replacement glyph. +func TestSplitCursorCoords(t *testing.T) { + // marker sits after 3 visible cells ("a", "b", "c") on the second line. + raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d" + clean, cur := splitCursor(raw, " ") + if cur == nil { + t.Fatal("expected a cursor when the marker is present") + } + if cur.X != 3 || cur.Y != 1 { + t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y) + } + if strings.Contains(clean, cursorMarker) { + t.Error("marker must be stripped from the returned frame") + } + if strings.Count(clean, " ") == 0 { + t.Error("marker should have been replaced by the repl glyph") + } +} + +// No marker (e.g. normal mode) → no cursor, frame unchanged. +func TestSplitCursorAbsent(t *testing.T) { + raw := "no caret here" + clean, cur := splitCursor(raw, "▏") + if cur != nil { + t.Error("no marker must yield a nil cursor (hidden)") + } + if clean != raw { + t.Error("frame must be unchanged when no marker is present") + } +} + +// The live View hides the cursor in normal mode and shows a real one in insert +// mode; the marker must never leak into the rendered content either way. +func TestViewCursorByMode(t *testing.T) { + m := inSessionModel(120, 40) + + m.editMode = ModeNormal + v := m.View() + if v.Cursor != nil { + t.Error("normal mode must hide the cursor (nil)") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "hello" + m.inputCursor = len(m.inputBuffer) + v = m.View() + if v.Cursor == nil { + t.Fatal("insert mode must show a real cursor") + } + if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 { + t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y) + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + // The static render keeps a visible glyph and also strips the marker. + if strings.Contains(m.render(), cursorMarker) { + t.Error("render() must strip the marker") + } + + // An open overlay keeps editMode==Insert (so closing resumes typing) but the + // composer must not draw a live cursor behind the modal. + m.overlay = OverlayPalette + if v = m.View(); v.Cursor != nil { + t.Error("an open overlay must suppress the composer cursor") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the marker with an overlay open") + } +} diff --git a/apps/tui-go/internal/app/input_history_test.go b/apps/tui-go/internal/app/input_history_test.go index 3b741567..58800758 100644 --- a/apps/tui-go/internal/app/input_history_test.go +++ b/apps/tui-go/internal/app/input_history_test.go @@ -78,12 +78,22 @@ func TestAnimatingGatesIdleRedraw(t *testing.T) { t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped") } + // Insert mode must NOT force the frame loop anymore: the composer caret is a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert + // screen holds still (and native text selection survives). m.editMode = ModeInsert - if !m.animating() { - t.Fatal("insert mode must animate (blinking caret)") + if m.animating() { + t.Fatal("insert mode must NOT animate — the native cursor blinks itself") } m.editMode = ModeNormal + // Other typing surfaces still draw their own carets and keep ticking. + m.steering = true + if !m.animating() { + t.Fatal("steering (drawn caret) must animate") + } + m.steering = false + m.sessions = []Session{{ID: "s1", Active: true}} if !m.animating() { t.Fatal("an active session must animate (spinner)") diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 58273bf0..83e75465 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -48,7 +48,10 @@ func (m Model) Init() tea.Cmd { // When false, the tick loop stops and the screen holds still — so a native terminal // text selection survives instead of being wiped by the next redraw. func (m Model) animating() bool { - if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing { + // Note: ModeInsert is intentionally NOT here — the composer caret is now a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no + // longer needs the frame loop. The other typing surfaces keep their drawn carets. + if m.steering || m.clarTyping || m.proposeTyping || m.configEditing { return true } if m.reconnecting { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6693c7d5..39cc70b6 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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 {