Skip to content

fix(tui): stop the composer cursor blinking while typing or unfocused#672

Open
euxaristia wants to merge 3 commits into
Gitlawb:mainfrom
euxaristia:euxaristia/composer-cursor-blink-focus
Open

fix(tui): stop the composer cursor blinking while typing or unfocused#672
euxaristia wants to merge 3 commits into
Gitlawb:mainfrom
euxaristia:euxaristia/composer-cursor-blink-focus

Conversation

@euxaristia

@euxaristia euxaristia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The composer's (prompt box's) text cursor blinked on a fixed 530ms timer unconditionally, including mid-keystroke, which made typing feel janky and disorienting since the cursor's job is to mark the current typing position.
  • Gated the existing blink tick (internal/tui/model.go, composerBlinkMsg handler) on two states instead of an unconditional toggle:
    • Actively typing (a keystroke landed within the last 500ms, reusing the existing lastCharTime tracker): cursor stays solid.
    • Unfocused (new terminalFocused field, driven by tea.FocusMsg/tea.BlurMsg): cursor hides entirely, matching the convention in Vim/iTerm2/most editors that a hidden caret is the unambiguous "not active" signal.
    • Idle and focused: unchanged, blinks as before.
  • Decoupled view.ReportFocus from the notifier (m.notifier != nil -> true). It was previously gated on notifier existence, which is effectively always true in production today, but left cursor-focus behavior fragile if that ever changes; focus reporting is a rendering concern independent of notification config.

Linked issue

None. Direct user report about the blinking feeling janky while typing.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./... (all green except the same pre-existing, unrelated internal/contextreport failure that also fails on unmodified main)
  • gofmt -l clean
  • New unit tests in internal/tui/model_test.go: cursor stays solid while typing, hidden while unfocused, resumes blinking after refocus + idle, unchanged toggle behavior when idle+focused
  • Manual smoke test: built the binary and drove it inside tmux, diffing raw capture-pane -e output over time to confirm the actual rendered cursor styling toggles while idle, stays solid across consecutive keystrokes sent at 200ms intervals, disappears immediately after injecting a terminal blur escape sequence (CSI O) and stays hidden across a blink interval, then resumes toggling after injecting a focus escape sequence (CSI I) and waiting past the idle threshold

Summary by CodeRabbit

  • Bug Fixes

    • Improved composer cursor behavior: the caret hides when the terminal is unfocused, then resumes correctly on refocus.
    • Keeps the cursor solid while typing/pasting, and resumes blinking after an idle period.
    • Ensures caret rendering updates immediately on keypress/paste, including in the command-argument hint area.
  • Tests

    • Added/expanded coverage for cursor visibility during typing, blur/focus transitions, idle blinking timing, immediate updates on keypress and paste, and rendering differences for the command-argument hint.

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.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 19a79725-ab76-4950-b05a-13b74c8d01e4

📥 Commits

Reviewing files that changed from the base of the PR and between ee2e157 and ce30079.

📒 Files selected for processing (2)
  • internal/tui/clipboard.go
  • internal/tui/model_test.go

Walkthrough

The TUI now tracks terminal focus and applies focus- and typing-aware composer cursor visibility rules. Focus reporting is always enabled, pasted or typed input shows the caret immediately, command hints follow cursor state, and tests cover focused, unfocused, typing, idle, and refocus behavior.

Changes

Composer cursor focus behavior

Layer / File(s) Summary
Terminal focus event wiring
internal/tui/model.go
The model stores and initializes terminal focus, updates it from focus events, and always enables Bubble Tea focus reporting.
Focus-aware cursor blinking and input handling
internal/tui/model.go, internal/tui/clipboard.go, internal/tui/model_test.go
Composer blink handling keeps the cursor visible during recent typing, hides it when unfocused, toggles it when focused and idle, and shows it immediately after keypresses or paste events.
Cursor-aware hint rendering and validation
internal/tui/model.go, internal/tui/model_test.go
Command argument hints render or suppress the caret according to cursor visibility, with tests covering the alternate rendering path.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • Gitlawb/zero#231: Both changes modify paste routing through internal/tui/clipboard.go’s routePaste.
  • Gitlawb/zero#573: Both changes modify composer timing and keypress handling in internal/tui/model.go.

Suggested reviewers: gnanam1990, vasanthdev2004, anandh8x

Sequence Diagram(s)

sequenceDiagram
  participant Terminal
  participant View
  participant BubbleTea
  participant TUIModel
  participant Composer
  Terminal->>View: focus or blur terminal
  View->>BubbleTea: report focus event
  BubbleTea->>TUIModel: deliver FocusMsg or BlurMsg
  TUIModel->>TUIModel: update terminalFocused and cursor visibility
  BubbleTea->>TUIModel: deliver keypress, paste, or blink message
  TUIModel->>Composer: render visible, hidden, or toggled caret
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: composer cursor blinking behavior is adjusted while typing and when unfocused.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/tui/model.go (1)

1221-1223: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update cursor visibility immediately on state transitions.

While the background blink timer correctly evaluates typing and focus states on each tick, relying solely on it to update cursor visibility introduces a noticeable UI delay. If the user types a key, focuses, or blurs while the cursor is in its hidden blink phase, the cursor will remain invisible for up to 500ms until the next tick fires. Update the visibility directly in the message handlers so the UI responds instantly.

  • internal/tui/model.go#L1221-L1223: Add m.composerCursorVisible = true to force the cursor visible immediately on keypress.
  • internal/tui/model.go#L1890-L1901: Add m.composerCursorVisible = true to tea.FocusMsg and m.composerCursorVisible = false to tea.BlurMsg to instantly sync the cursor with terminal focus.
🐛 Proposed fixes
 		m.lastKeyTime = now
 		if !keyIs(msg, tea.KeyEnter) {
 			m.lastCharTime = now
 		}
+		m.composerCursorVisible = true
 	case tea.FocusMsg:
 		m.terminalFocused = true
+		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)
 		}
 		return m, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 1221 - 1223, Update internal/tui/model.go
at lines 1221-1223 to set m.composerCursorVisible = true on keypress alongside
the existing m.lastCharTime update. In the tea.FocusMsg and tea.BlurMsg handlers
at lines 1890-1901, set composerCursorVisible to true and false respectively so
visibility changes immediately with terminal focus.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/tui/model.go`:
- Around line 1221-1223: Update internal/tui/model.go at lines 1221-1223 to set
m.composerCursorVisible = true on keypress alongside the existing m.lastCharTime
update. In the tea.FocusMsg and tea.BlurMsg handlers at lines 1890-1901, set
composerCursorVisible to true and false respectively so visibility changes
immediately with terminal focus.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1112d40c-81ce-4467-972d-7a8341d16a6f

📥 Commits

Reviewing files that changed from the base of the PR and between 80c39aa and e70cc44.

📒 Files selected for processing (2)
  • internal/tui/model.go
  • internal/tui/model_test.go

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Apply the new caret state at the input and focus transitions
    internal/tui/model.go:1221
    A keypress only records lastCharTime, while focus and blur only update terminalFocused; composerCursorVisible is changed solely by the next 530 ms composerBlinkMsg. If an idle tick has hidden the caret, the next character is rendered without a caret until a later tick; a blur likewise leaves a visible caret, and a refocus can leave it hidden, for up to a tick. This contradicts the solid-while-typing and hidden-while-unfocused behavior, and the added tests miss it because they explicitly send a blink message after every transition. Update visibility synchronously for the relevant key/focus/blur messages and cover the state immediately after each message.

  • [P2] Route required-command hints through the caret visibility state
    internal/tui/model.go:3778
    When a slash command is awaiting a required argument, composerLine takes the commandArgumentHintComposerLine branch, which always calls composerCursor and never reads m.composerCursorVisible. Consequently that composer caret remains visible while the terminal is unfocused and never observes the new typing/idle blink rules. Pass the visibility state into this renderer (and add an argument-hint case) so this alternate composer path follows the same focus contract.

…nt 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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed ee2e157 for both findings, plus CodeRabbit's outside-diff comment, which described the same defect as the first finding.

  • Keypress, FocusMsg, and BlurMsg now set composerCursorVisible in their own handlers instead of waiting for the next composerBlinkMsg tick. That closes all three windows: a typed character rendering caret-less after the blink phase hid it, a blur leaving a visible caret in an unfocused terminal, and a refocus keeping the caret hidden for up to a tick.
  • commandArgumentHintComposerLine now 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.
  • The new tests (TestComposerCursorShowsImmediatelyOnKeypress, TestComposerCursorSyncsImmediatelyOnFocusChange, TestCommandArgumentHintFollowsCursorVisibility) assert the state immediately after each message with no blink tick in between, which is the coverage gap the review pointed out in the earlier tests.

go build, go vet, and the tui test suite pass locally, except TestAltScreenTranscriptScrollKeepsFooterFixed, which fails on this branch without these changes and passes on current main, so it is a stale-branch artifact rather than a regression from this change.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Apply the caret state when pasting into the composer
    internal/tui/clipboard.go:58
    tea.PasteMsg and right-click clipboardReadMsg both reach routePaste, which updates the editable composer without refreshing lastCharTime or composerCursorVisible. If the idle blink has just hidden the caret, pasting text leaves that newly populated composer caret-less until the next 530 ms tick; because the timestamp is stale, that tick toggles instead of entering the solid-while-typing state. This is the same immediate-input transition fixed for tea.KeyPressMsg, so refresh the caret/timing state for the composer-paste branch and cover a paste immediately after a hidden blink.

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.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the paste finding: routePaste now refreshes lastCharTime and sets the caret visible before any early return, covering both the bracketed tea.PasteMsg path and the right-click clipboardReadMsg path, so a paste immediately after a hidden blink renders a solid caret and the next tick holds solid instead of toggling off the stale idle timestamp. TestComposerCursorShowsImmediatelyOnPaste covers the hidden-blink-then-paste sequence with no blink tick in between.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. The three-state blink handler is clean unfocused hides, active typing stays solid via the 500ms idle threshold, and idle+focused toggles as before and syncing the caret immediately on keypress/paste/focus/blur closes the race where a tick could leave it in the wrong state for up to a full blink period. One thing worth a glance: ReportFocus is now always on (CSI ?1004h) regardless of notifier config, so terminals that previously never received focus events will now start sending them; it's a standard, widely-ignored DEC mode and the true default handles terminals that never emit events, but it does broaden the surface. New tests cover every branch and pass; the only failing test (TestHandleAddDirCommand) reproduces on clean origin/main and is unrelated.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants