From e70cc4463baee10a2a9cf1e8ef2fcab8e6bdd356 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:48:56 -0400 Subject: [PATCH 1/3] fix(tui): stop the composer cursor blinking while typing or unfocused The composer's cursor blinked on a fixed timer unconditionally, including mid-keystroke, which made typing feel janky since the cursor's job is to mark the current position. Gate the existing blink tick on two states instead: force solid while a keystroke landed within the last 500ms (reusing the existing lastCharTime tracker), and hide it entirely while the terminal window is unfocused (new terminalFocused field, driven by tea.FocusMsg/ BlurMsg). Idle and focused keeps the prior blink behavior unchanged. Also decouples view.ReportFocus from the notifier (it was previously gated on m.notifier != nil, which is effectively always true in production but left cursor-focus behavior fragile if that ever changes) so focus reporting is requested unconditionally. --- internal/tui/model.go | 32 +++++++++++-- internal/tui/model_test.go | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index eceebfc2d..f112351dd 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -199,8 +199,13 @@ type model struct { // lastKeyTime tracks every keypress timestamp for burst calculation. lastKeyTime time.Time // burstCount counts consecutive keypresses within 100ms (paste mode). - burstCount int - queuedMessage string + burstCount int + // terminalFocused tracks whether the terminal window currently has focus, per + // tea.FocusMsg/tea.BlurMsg. Defaults to true since many terminals/multiplexers + // never send focus events at all, and defaulting to "unfocused" would wrongly + // hide the cursor for those users. + terminalFocused bool + queuedMessage string // loops holds the session's active /loop definitions (see loop.go). activeLoopID // tags the in-flight run when it is a loop iteration (empty = a user turn), so the // completion seam knows whether to advance a loop. loopSeq invalidates a stale @@ -770,6 +775,7 @@ func newModel(ctx context.Context, options Options) model { userCommands: loadedUserCommands, loadSkills: options.LoadSkills, composerCursorVisible: true, + terminalFocused: true, userConfigPath: options.UserConfigPath, doctorUserConfigPath: doctorUserConfigPath, projectConfigPath: options.ProjectConfigPath, @@ -881,6 +887,11 @@ const ( // composerCursorBlinkInterval is the on/off period of the composer text cursor. const composerCursorBlinkInterval = 530 * time.Millisecond +// composerTypingIdleThreshold is how long a typing pause must last before the +// cursor resumes blinking; comfortably above normal inter-keystroke gaps +// (~150-300ms) so it won't flicker mid-sentence. +const composerTypingIdleThreshold = 500 * time.Millisecond + // composerBlinkMsg toggles the composer cursor's visibility each tick. The custom // composer render draws its own cursor (not textinput's), so it drives its own // blink rather than relying on textinput.Blink. @@ -1074,7 +1085,14 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd { func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case composerBlinkMsg: - m.composerCursorVisible = !m.composerCursorVisible + switch { + case !m.terminalFocused: + m.composerCursorVisible = false // hidden while unfocused + case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold: + m.composerCursorVisible = true // solid while actively typing + default: + m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before + } return m, composerBlinkCmd() case tea.BackgroundColorMsg: // Terminal background-color reply (from Init's RequestBackgroundColor). In @@ -1870,11 +1888,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.recomputeSuggestions() return m, cmd case tea.FocusMsg: + m.terminalFocused = true if m.notifier != nil { m.notifier.SetFocused(true) } return m, nil case tea.BlurMsg: + m.terminalFocused = false if m.notifier != nil { m.notifier.SetFocused(false) } @@ -2541,7 +2561,11 @@ func (m model) View() tea.View { if m.altScreen { view.BackgroundColor = zeroTheme.bgPanel } - view.ReportFocus = m.notifier != nil + // Always requested, independent of the notifier: the composer cursor's + // focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg + // regardless of notification config. A standard, widely supported DEC + // private mode (CSI ?1004h) that unsupported terminals silently ignore. + view.ReportFocus = true // Voice mode's Space-hold gesture needs key-release events (Kitty protocol). // Request them only while voice mode is on — the renderer re-sends the request // only when the value changes, so gating this costs nothing (§10). diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e9fd5de21..90d5c2680 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2647,6 +2647,98 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) { } } +func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) { + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + now := base + m := model{ + now: func() time.Time { return now }, + terminalFocused: true, + lastCharTime: base, + composerCursorVisible: true, + } + + // Each iteration simulates a keystroke (refreshing lastCharTime) followed by + // a blink tick within the typing-idle threshold: cursor must stay solid + // rather than toggling off, however many ticks land. + for i := 0; i < 3; i++ { + now = now.Add(200 * time.Millisecond) + m.lastCharTime = now + updated, _ := m.Update(composerBlinkMsg{}) + m = updated.(model) + if !m.composerCursorVisible { + t.Fatalf("tick %d: expected cursor to stay visible while typing, got hidden", i) + } + } +} + +func TestComposerBlinkHiddenWhileUnfocused(t *testing.T) { + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + m := model{ + now: func() time.Time { return base }, + lastCharTime: base.Add(-time.Hour), // long idle, irrelevant while unfocused + composerCursorVisible: true, + } + + updated, _ := m.Update(tea.BlurMsg{}) + m = updated.(model) + + for i := 0; i < 3; i++ { + updated, _ = m.Update(composerBlinkMsg{}) + m = updated.(model) + if m.composerCursorVisible { + t.Fatalf("tick %d: expected cursor to stay hidden while unfocused, got visible", i) + } + } +} + +func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) { + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + m := model{ + now: func() time.Time { return base }, + lastCharTime: base.Add(-time.Hour), // stale: well past the idle threshold + composerCursorVisible: true, + } + + updated, _ := m.Update(tea.BlurMsg{}) + m = updated.(model) + updated, _ = m.Update(tea.FocusMsg{}) + m = updated.(model) + if !m.terminalFocused { + t.Fatal("expected terminalFocused to be true after FocusMsg") + } + + updated, _ = m.Update(composerBlinkMsg{}) + m = updated.(model) + first := m.composerCursorVisible + updated, _ = m.Update(composerBlinkMsg{}) + m = updated.(model) + second := m.composerCursorVisible + if first == second { + t.Fatalf("expected blink to toggle once idle+focused, got %v then %v", first, second) + } +} + +func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) { + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + m := model{ + now: func() time.Time { return base }, + terminalFocused: true, + lastCharTime: base.Add(-time.Hour), + composerCursorVisible: true, + } + + updated, _ := m.Update(composerBlinkMsg{}) + m = updated.(model) + if m.composerCursorVisible { + t.Fatal("expected cursor to toggle off on first idle+focused tick") + } + updated, _ = m.Update(composerBlinkMsg{}) + m = updated.(model) + if !m.composerCursorVisible { + t.Fatal("expected cursor to toggle back on on second idle+focused tick") + } +} + func TestScrimViewportLine(t *testing.T) { // Blank lines are left untouched (no scrim). if got := scrimViewportLine(" ", 10); got != " " { From ee2e15749b2c7c464ea32ea6a621df2320174273 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:28:02 -0400 Subject: [PATCH 2/3] fix(tui): apply caret visibility synchronously and in the argument-hint line - A keypress, focus, or blur now updates composerCursorVisible in its own message handler instead of waiting for the next 530ms composerBlinkMsg tick, which could render a typed character caret-less, leave a visible caret in an unfocused terminal, or keep it hidden after a refocus. - commandArgumentHintComposerLine takes the caret visibility state instead of painting its cursor cell unconditionally, so the required-argument hint path follows the same focus and blink contract as the main composer renderer. - New tests assert the state immediately after each message, with no blink tick in between, and cover the argument-hint render in both states. --- internal/tui/model.go | 24 ++++++++++++++--- internal/tui/model_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index f112351dd..15e842eb2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1221,6 +1221,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if !keyIs(msg, tea.KeyEnter) { m.lastCharTime = now } + // Enter the solid-while-typing state right away: only composerBlinkMsg + // evaluates the typing threshold, so if the blink phase had just hidden + // the caret, the typed character would render caret-less for up to a + // full tick before the timer catches up. + m.composerCursorVisible = true if m.setup.visible { return m.handleSetupKey(msg) } @@ -1889,12 +1894,18 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.FocusMsg: m.terminalFocused = true + // Sync the caret with focus immediately: leaving it to the next + // composerBlinkMsg tick can keep it hidden for up to a tick after the + // terminal regains focus (and, on blur below, leave it visible in an + // unfocused terminal for the same window). + m.composerCursorVisible = true if m.notifier != nil { m.notifier.SetFocused(true) } return m, nil case tea.BlurMsg: m.terminalFocused = false + m.composerCursorVisible = false if m.notifier != nil { m.notifier.SetFocused(false) } @@ -3499,7 +3510,7 @@ func (m model) composerLine(width int) string { } if argumentHint != "" { input.SetWidth(0) - return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width) + return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width) } previews := validComposerPastePreviews(state, m.composerPastePreviews) displayState := composerDisplayStateForPastePreviews(state, previews) @@ -3775,16 +3786,23 @@ func composerCursorForVisualColumn(state composerState, segment composerVisualLi return segment.end } -func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) string { +func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) string { hintRunes := []rune(argumentHint) if len(hintRunes) == 0 { return input.View() } displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace) + // This alternate composer path must follow the same caret contract as + // renderComposerInput: hidden while the terminal is unfocused and blinking + // per composerCursorVisible, not a permanently painted cursor cell. + cursor := zeroTheme.faint.Render(string(hintRunes[0])) + if cursorVisible { + cursor = composerCursor(cursor) + } return zeroTheme.userPrompt.Render(input.Prompt) + zeroTheme.ink.Inline(true).Render(displayValue) + zeroTheme.faint.Render(" ") + - composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) + + cursor + zeroTheme.faint.Render(string(hintRunes[1:])) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 90d5c2680..a26fc8c19 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -11,6 +11,7 @@ import ( "time" "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -2739,6 +2740,59 @@ func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) { } } +func TestComposerCursorShowsImmediatelyOnKeypress(t *testing.T) { + // The blink phase may have just hidden the caret when the user starts + // typing; the typed character must render with a caret immediately, not + // after the next composerBlinkMsg tick evaluates the typing threshold. + m := newModel(context.Background(), Options{}) + m.terminalFocused = true + m.composerCursorVisible = false + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"})) + m = updated.(model) + if !m.composerCursorVisible { + t.Fatal("expected caret visible immediately after a keypress, before any blink tick") + } +} + +func TestComposerCursorSyncsImmediatelyOnFocusChange(t *testing.T) { + // Focus transitions must apply the caret contract synchronously: a blur + // with no blink tick yet must not leave a visible caret in an unfocused + // terminal, and a refocus must not leave the caret hidden for a tick. + m := model{composerCursorVisible: true, terminalFocused: true} + + updated, _ := m.Update(tea.BlurMsg{}) + m = updated.(model) + if m.composerCursorVisible { + t.Fatal("expected caret hidden immediately after BlurMsg, before any blink tick") + } + + updated, _ = m.Update(tea.FocusMsg{}) + m = updated.(model) + if !m.composerCursorVisible { + t.Fatal("expected caret visible immediately after FocusMsg, before any blink tick") + } +} + +func TestCommandArgumentHintFollowsCursorVisibility(t *testing.T) { + // The argument-hint composer line is an alternate render path that used to + // paint its caret cell unconditionally, ignoring focus and blink state. + input := textinput.New() + input.SetValue("/rewind ") + + visible := commandArgumentHintComposerLine(input, "hint", true) + hidden := commandArgumentHintComposerLine(input, "hint", false) + if visible == hidden { + t.Fatal("expected the rendered hint line to differ between visible and hidden caret states") + } + if want := composerCursor(zeroTheme.faint.Render("h")); !strings.Contains(visible, want) { + t.Fatalf("expected visible-caret render to contain the styled cursor cell, got %q", visible) + } + if got := hidden; strings.Contains(got, composerCursor(zeroTheme.faint.Render("h"))) { + t.Fatalf("expected hidden-caret render to drop the styled cursor cell, got %q", got) + } +} + func TestScrimViewportLine(t *testing.T) { // Blank lines are left untouched (no scrim). if got := scrimViewportLine(" ", 10); got != " " { From ce30079d13a5ae1e366c43a1d90d6f486a2adf0c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:52:49 -0400 Subject: [PATCH 3/3] fix(tui): apply the caret state on composer paste A paste is the same immediate-input transition as a keypress: routePaste now refreshes lastCharTime and sets the caret visible for both the bracketed tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after the blink phase hid the caret renders solid immediately instead of waiting for a tick that would toggle off a stale idle state. --- internal/tui/clipboard.go | 8 ++++++++ internal/tui/model_test.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index e36d088b7..b171307b6 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -63,6 +63,14 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) { // do it once here, before any early return, so both the bracketed-paste // and right-click-paste paths are covered uniformly. m = m.disarmCancelConfirmation() + // Same immediate-input transition as the keypress hook: text entering the + // composer must land the caret in its solid-while-typing state right away + // and refresh the typing timestamp. Without this, a paste right after the + // blink phase hid the caret leaves the newly populated composer caret-less + // until the next 530ms tick, and the stale timestamp makes that tick + // toggle instead of going solid. + m.lastCharTime = m.now() + m.composerCursorVisible = true if content == "" { // Empty text clipboard — the user may have pasted a screenshot. // Probe the OS clipboard for image content asynchronously. diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index a26fc8c19..5f9359e21 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2774,6 +2774,27 @@ func TestComposerCursorSyncsImmediatelyOnFocusChange(t *testing.T) { } } +func TestComposerCursorShowsImmediatelyOnPaste(t *testing.T) { + // A paste is the same immediate-input transition as a keypress: if the + // blink phase had just hidden the caret, the freshly pasted composer must + // render with a solid caret right away, and the typing timestamp must be + // refreshed so the next blink tick holds solid instead of toggling off a + // stale idle state. + m := newModel(context.Background(), Options{}) + m.terminalFocused = true + m.composerCursorVisible = false + m.lastCharTime = time.Now().Add(-time.Hour) // stale: well past the idle threshold + + updated, _ := m.Update(tea.PasteMsg{Content: "pasted text"}) + m = updated.(model) + if !m.composerCursorVisible { + t.Fatal("expected caret visible immediately after a paste, before any blink tick") + } + if time.Since(m.lastCharTime) > time.Minute { + t.Fatalf("expected the paste to refresh lastCharTime, still %v old", time.Since(m.lastCharTime)) + } +} + func TestCommandArgumentHintFollowsCursorVisibility(t *testing.T) { // The argument-hint composer line is an alternate render path that used to // paint its caret cell unconditionally, ignoring focus and blink state.