diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index e36d088b..b171307b 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.go b/internal/tui/model.go index eceebfc2..15e842eb 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 @@ -1203,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) } @@ -1870,11 +1893,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.recomputeSuggestions() 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) } @@ -2541,7 +2572,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). @@ -3475,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) @@ -3751,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 e9fd5de2..5f9359e2 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" @@ -2647,6 +2648,172 @@ 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 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 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. + 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 != " " {