Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 49 additions & 7 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:]))
}

Expand Down
167 changes: 167 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 != " " {
Expand Down
Loading