Skip to content
Merged
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
4 changes: 4 additions & 0 deletions internal/ui/center/model_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
cmd := m.updatePTYFlush(msg)
cmds = append(cmds, cmd)

case PTYCursorRefresh:
cmd := m.updatePTYCursorRefresh(msg)
cmds = append(cmds, cmd)

case PTYStopped:
cmd := m.updatePTYStopped(msg)
cmds = append(cmds, cmd)
Expand Down
67 changes: 67 additions & 0 deletions internal/ui/center/model_input_lifecycle_cursor_refresh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package center

import (
"testing"
"time"

"github.com/andyrewlee/amux/internal/vterm"
)

func TestUpdatePTYOutputSetsAndClearsCursorRefreshSchedule(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
wsID := string(ws.ID())
term := vterm.New(10, 3)

tab := &Tab{
ID: TabID("tab-cursor-refresh"),
Assistant: "codex",
Workspace: ws,
Terminal: term,
Running: true,
}
m.tabsByWorkspace[wsID] = []*Tab{tab}
m.activeTabByWorkspace[wsID] = 0
m.SetWorkspace(ws)

_ = m.updatePTYOutput(PTYOutput{
WorkspaceID: wsID,
TabID: tab.ID,
Data: []byte("hello"),
})

tab.mu.Lock()
scheduled := tab.cursorRefreshScheduled
tab.mu.Unlock()
if !scheduled {
t.Fatal("expected cursor refresh scheduling after chat PTY output")
}

cmd := m.updatePTYCursorRefresh(PTYCursorRefresh{
WorkspaceID: wsID,
TabID: tab.ID,
})
if cmd == nil {
t.Fatal("expected cursor refresh to reschedule while suppression deadline is still in the future")
}

tab.mu.Lock()
stillScheduled := tab.cursorRefreshScheduled
tab.cursorRefreshDueAt = time.Now().Add(-time.Millisecond)
tab.mu.Unlock()
if !stillScheduled {
t.Fatal("expected cursor refresh scheduling to remain active until due time elapses")
}

_ = m.updatePTYCursorRefresh(PTYCursorRefresh{
WorkspaceID: wsID,
TabID: tab.ID,
})

tab.mu.Lock()
cleared := tab.cursorRefreshScheduled
tab.mu.Unlock()
if cleared {
t.Fatal("expected cursor refresh scheduling flag to clear on refresh tick")
}
}
45 changes: 45 additions & 0 deletions internal/ui/center/model_input_lifecycle_pty.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ func (m *Model) updatePTYOutput(msg PTYOutput) tea.Cmd {
tab.pendingVisibleSeq++
tab.mu.Unlock()
}
refreshDelay := cursorSuppressWindow + 20*time.Millisecond
tab.mu.Lock()
tab.cursorRefreshDueAt = now.Add(refreshDelay)
// Bubble Tea processes Update messages serially; this flag-gated
// scheduling assumes a single writer for this tab state.
scheduleCursorRefresh := !tab.cursorRefreshScheduled
if scheduleCursorRefresh {
tab.cursorRefreshScheduled = true
}
tab.mu.Unlock()
if scheduleCursorRefresh {
workspaceID := msg.WorkspaceID
tabID := msg.TabID
cmds = append(cmds, common.SafeTick(refreshDelay, func(time.Time) tea.Msg {
return PTYCursorRefresh{WorkspaceID: workspaceID, TabID: tabID}
}))
}
}
if !tab.flushScheduled {
tab.flushScheduled = true
Expand All @@ -167,6 +184,34 @@ func (m *Model) updatePTYOutput(msg PTYOutput) tea.Cmd {
return common.SafeBatch(cmds...)
}

func (m *Model) updatePTYCursorRefresh(msg PTYCursorRefresh) tea.Cmd {
tab := m.getTabByID(msg.WorkspaceID, msg.TabID)
if tab == nil || tab.isClosed() {
return nil
}
tab.mu.Lock()
if !tab.cursorRefreshScheduled {
tab.mu.Unlock()
return nil
}
remaining := time.Until(tab.cursorRefreshDueAt)
if remaining <= 0 {
tab.cursorRefreshScheduled = false
tab.cursorRefreshDueAt = time.Time{}
tab.mu.Unlock()
return nil
}
if remaining < time.Millisecond {
remaining = time.Millisecond
}
workspaceID := msg.WorkspaceID
tabID := msg.TabID
tab.mu.Unlock()
return common.SafeTick(remaining, func(time.Time) tea.Msg {
return PTYCursorRefresh{WorkspaceID: workspaceID, TabID: tabID}
})
}

// updatePTYFlush handles PTYFlush.
func (m *Model) updatePTYFlush(msg PTYFlush) tea.Cmd {
var cmds []tea.Cmd
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/center/model_pty_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ type PTYFlush struct {
TabID TabID
}

// PTYCursorRefresh triggers a render pass after cursor suppression windows.
type PTYCursorRefresh struct {
WorkspaceID string
TabID TabID
}

// PTYStopped signals that the PTY read loop has stopped (terminal closed or error)
type PTYStopped struct {
WorkspaceID string
Expand Down
23 changes: 20 additions & 3 deletions internal/ui/center/model_pty_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import (
"github.com/andyrewlee/amux/internal/ui/compositor"
)

const tabActiveWindow = 2 * time.Second
const (
tabActiveWindow = 2 * time.Second
cursorSuppressWindow = 450 * time.Millisecond
)

// HasRunningAgents returns whether any tab has an active agent across workspaces.
func (m *Model) HasRunningAgents() bool {
Expand Down Expand Up @@ -206,6 +209,17 @@ func (m *Model) TerminalLayerWithCursorOwner(cursorOwner bool) *compositor.VTerm
if !cursorOwner {
showCursor = false
}
// Suppress chat cursor paint only for a short window after raw PTY output.
// This reduces visible cursor-jumping during streaming without hiding the
// cursor broadly when a tab is idle.
if showCursor &&
m.isChatTab(tab) &&
tab.Running &&
!tab.Detached &&
!tab.lastOutputAt.IsZero() &&
time.Since(tab.lastOutputAt) < cursorSuppressWindow {
showCursor = false
}
if tab.cachedSnap != nil &&
tab.cachedVersion == version &&
tab.cachedShowCursor == showCursor {
Expand All @@ -214,8 +228,11 @@ func (m *Model) TerminalLayerWithCursorOwner(cursorOwner bool) *compositor.VTerm
return compositor.NewVTermLayer(tab.cachedSnap)
}

// Create new snapshot while holding the lock, reusing cached lines when possible.
snap := compositor.NewVTermSnapshotWithCache(tab.Terminal, showCursor, tab.cachedSnap)
// Create new snapshot while holding the lock.
// Do not pass the previous snapshot for reuse: NewVTermSnapshotWithCache
// mutates the provided snapshot/rows in-place, which can mutate a snapshot
// already handed to a previously returned layer.
snap := compositor.NewVTermSnapshot(tab.Terminal, showCursor)
if snap == nil {
return nil
}
Expand Down
98 changes: 98 additions & 0 deletions internal/ui/center/model_pty_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package center

import (
"testing"
"time"

"github.com/andyrewlee/amux/internal/vterm"
)
Expand Down Expand Up @@ -68,6 +69,103 @@ func TestTerminalLayerPreservesCursorHiddenForNonChatTabs(t *testing.T) {
}
}

func TestIsChatTabUsesConfigMapWhenPresent(t *testing.T) {
m := newTestModel()
tab := &Tab{Assistant: "cursor"}

if m.isChatTab(tab) {
t.Fatal("expected assistant missing from config map to be treated as non-chat when config is present")
}
}

func TestTerminalLayerHidesCursorWhileChatTabStreaming(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
wsID := string(ws.ID())
term := vterm.New(10, 3)

m.tabsByWorkspace[wsID] = []*Tab{
{
ID: TabID("tab-chat-streaming"),
Assistant: "codex",
Workspace: ws,
Terminal: term,
Running: true,
lastOutputAt: time.Now(),
},
}
m.activeTabByWorkspace[wsID] = 0
m.SetWorkspace(ws)
m.Focus()

layer := m.TerminalLayer()
if layer == nil || layer.Snap == nil {
t.Fatal("expected terminal layer snapshot")
}
if layer.Snap.ShowCursor {
t.Fatal("expected cursor to be hidden while chat tab is actively streaming")
}
}

func TestTerminalLayerShowsCursorForIdleBootstrapChatTab(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
wsID := string(ws.ID())
term := vterm.New(10, 3)

m.tabsByWorkspace[wsID] = []*Tab{
{
ID: TabID("tab-chat-bootstrap"),
Assistant: "codex",
Workspace: ws,
Terminal: term,
Running: true,
bootstrapActivity: true,
bootstrapLastOutputAt: time.Now(),
},
}
m.activeTabByWorkspace[wsID] = 0
m.SetWorkspace(ws)
m.Focus()

layer := m.TerminalLayer()
if layer == nil || layer.Snap == nil {
t.Fatal("expected terminal layer snapshot")
}
if !layer.Snap.ShowCursor {
t.Fatal("expected cursor to remain visible for idle bootstrap tab without recent output")
}
}

func TestTerminalLayerShowsCursorAfterSuppressWindow(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
wsID := string(ws.ID())
term := vterm.New(10, 3)

m.tabsByWorkspace[wsID] = []*Tab{
{
ID: TabID("tab-chat-suppress-expired"),
Assistant: "codex",
Workspace: ws,
Terminal: term,
Running: true,
lastOutputAt: time.Now().Add(-(cursorSuppressWindow + 100*time.Millisecond)),
},
}
m.activeTabByWorkspace[wsID] = 0
m.SetWorkspace(ws)
m.Focus()

layer := m.TerminalLayer()
if layer == nil || layer.Snap == nil {
t.Fatal("expected terminal layer snapshot")
}
if !layer.Snap.ShowCursor {
t.Fatal("expected cursor to be visible when suppression window has elapsed")
}
}

func TestTerminalLayerNormalizesSyntheticCursorCellForChatTabs(t *testing.T) {
m := newTestModel()
ws := newTestWorkspace("ws", "/repo/ws")
Expand Down
42 changes: 22 additions & 20 deletions internal/ui/center/model_tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,28 @@ type Tab struct {
readerActiveState uint32 // Mirrors readerActive for lock-free atomic reads
// Buffer PTY output to avoid rendering partial screen updates.

pendingOutput []byte
ptyNoiseTrailing []byte
flushScheduled bool
lastOutputAt time.Time
lastVisibleOutput time.Time
pendingVisibleOutput bool
pendingVisibleSeq uint64
activityDigest [16]byte
activityDigestInit bool
lastActivityTagAt time.Time
activityANSIState ansiActivityState
lastInputTagAt time.Time
lastUserInputAt time.Time
bootstrapActivity bool
bootstrapLastOutputAt time.Time
flushPendingSince time.Time
ptyRows int
ptyCols int
ptyMsgCh chan tea.Msg
readerCancel chan struct{}
pendingOutput []byte
ptyNoiseTrailing []byte
flushScheduled bool
lastOutputAt time.Time
cursorRefreshScheduled bool
cursorRefreshDueAt time.Time
lastVisibleOutput time.Time
pendingVisibleOutput bool
pendingVisibleSeq uint64
activityDigest [16]byte
activityDigestInit bool
lastActivityTagAt time.Time
activityANSIState ansiActivityState
lastInputTagAt time.Time
lastUserInputAt time.Time
bootstrapActivity bool
bootstrapLastOutputAt time.Time
flushPendingSince time.Time
ptyRows int
ptyCols int
ptyMsgCh chan tea.Msg
readerCancel chan struct{}
// Mouse selection state
Selection common.SelectionState
selectionScroll common.SelectionScrollState
Expand Down
38 changes: 38 additions & 0 deletions internal/ui/sidebar/terminal_cursor_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,41 @@ func TestTerminalLayerWithCursorOwner_ShowsCursorWhenOwner(t *testing.T) {
t.Fatal("expected cursor visible when sidebar pane owns cursor")
}
}

func TestTerminalLayerWithCursorOwner_DoesNotMutatePriorSnapshotOnCacheMiss(t *testing.T) {
m := setupTerminalOwnerModel(t)
ts := m.getTerminal()
if ts == nil || ts.VTerm == nil {
t.Fatal("expected terminal state")
}

ts.mu.Lock()
ts.VTerm.Write([]byte("a"))
ts.mu.Unlock()

layer1 := m.TerminalLayerWithCursorOwner(true)
if layer1 == nil || layer1.Snap == nil {
t.Fatal("expected initial terminal layer snapshot")
}
if got := layer1.Snap.Screen[0][0].Rune; got != 'a' {
t.Fatalf("expected initial snapshot rune 'a', got %q", got)
}

ts.mu.Lock()
ts.VTerm.Write([]byte("\rb"))
ts.mu.Unlock()

layer2 := m.TerminalLayerWithCursorOwner(true)
if layer2 == nil || layer2.Snap == nil {
t.Fatal("expected second terminal layer snapshot")
}
if got := layer2.Snap.Screen[0][0].Rune; got != 'b' {
t.Fatalf("expected second snapshot rune 'b', got %q", got)
}
if got := layer1.Snap.Screen[0][0].Rune; got != 'a' {
t.Fatalf("expected first snapshot to remain unchanged, got %q", got)
}
if layer1.Snap == layer2.Snap {
t.Fatal("expected distinct snapshot objects across cache misses")
}
}
Loading