Skip to content

Commit 233ff6e

Browse files
committed
Add load-aware PTY flush timing and dashboard state updates
1 parent dd9bd34 commit 233ff6e

10 files changed

Lines changed: 186 additions & 42 deletions

internal/ui/center/model.go

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,15 @@ type Tab struct {
5050
SessionName string
5151
Detached bool
5252
// reattachInFlight prevents overlapping reattach attempts for the same tab.
53-
reattachInFlight bool
54-
Terminal *vterm.VTerm // Virtual terminal emulator with scrollback
55-
DiffViewer *diff.Model // Native diff viewer (replaces PTY-based viewer)
56-
mu sync.Mutex // Protects Terminal
57-
closed uint32
58-
closing uint32
59-
Running bool // Whether the agent is actively running
60-
readerActive bool // Guard to ensure only one PTY read loop per tab
53+
reattachInFlight bool
54+
Terminal *vterm.VTerm // Virtual terminal emulator with scrollback
55+
DiffViewer *diff.Model // Native diff viewer (replaces PTY-based viewer)
56+
mu sync.Mutex // Protects Terminal
57+
closed uint32
58+
closing uint32
59+
Running bool // Whether the agent is actively running
60+
readerActive bool // Guard to ensure only one PTY read loop per tab
61+
readerActiveState uint32 // Mirrors readerActive for lock-free atomic reads
6162
// Buffer PTY output to avoid rendering partial screen updates.
6263

6364
pendingOutput []byte
@@ -117,6 +118,9 @@ func (t *Tab) markClosed() {
117118
type Model struct {
118119
// State
119120
workspace *data.Workspace
121+
workspaceIDCached string
122+
workspaceIDRepo string
123+
workspaceIDRoot string
120124
tabsByWorkspace map[string][]*Tab // tabs per workspace ID
121125
activeTabByWorkspace map[string]int // active tab index per workspace
122126
focused bool
@@ -127,6 +131,8 @@ type Model struct {
127131
tabEvents chan tabEvent
128132
tabActorReady uint32
129133
tabActorHeartbeat int64
134+
flushLoadSampleAt time.Time
135+
cachedBusyTabCount int
130136

131137
// Layout
132138
width int
@@ -276,12 +282,35 @@ func (m *Model) noteTabActorHeartbeat() {
276282
}
277283
}
278284

285+
func (m *Model) setWorkspace(ws *data.Workspace) {
286+
m.workspace = ws
287+
m.workspaceIDCached = ""
288+
m.workspaceIDRepo = ""
289+
m.workspaceIDRoot = ""
290+
if ws == nil {
291+
return
292+
}
293+
m.workspaceIDRepo = ws.Repo
294+
m.workspaceIDRoot = ws.Root
295+
m.workspaceIDCached = string(ws.ID())
296+
}
297+
279298
// workspaceID returns the ID of the current workspace, or empty string
280299
func (m *Model) workspaceID() string {
281300
if m.workspace == nil {
301+
m.workspaceIDCached = ""
302+
m.workspaceIDRepo = ""
303+
m.workspaceIDRoot = ""
282304
return ""
283305
}
284-
return string(m.workspace.ID())
306+
if m.workspaceIDCached == "" ||
307+
m.workspaceIDRepo != m.workspace.Repo ||
308+
m.workspaceIDRoot != m.workspace.Root {
309+
m.workspaceIDRepo = m.workspace.Repo
310+
m.workspaceIDRoot = m.workspace.Root
311+
m.workspaceIDCached = string(m.workspace.ID())
312+
}
313+
return m.workspaceIDCached
285314
}
286315

287316
// getTabs returns the tabs for the current workspace

internal/ui/center/model_lifecycle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func (m *Model) Focused() bool {
4343

4444
// SetWorkspace sets the active workspace.
4545
func (m *Model) SetWorkspace(ws *data.Workspace) {
46-
m.workspace = ws
46+
m.setWorkspace(ws)
4747
if ws == nil {
4848
return
4949
}

internal/ui/center/model_pty_config.go

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@ const (
99
ptyFlushQuietAlt = 8 * time.Millisecond
1010
ptyFlushMaxAlt = 32 * time.Millisecond
1111
// Inactive tabs still need to advance their terminal state, but can flush less frequently.
12-
ptyFlushInactiveMultiplier = 4
13-
ptyFlushChunkSize = 32 * 1024
14-
ptyReadBufferSize = 32 * 1024
15-
ptyReadQueueSize = 64
16-
ptyFrameInterval = time.Second / 60
17-
ptyMaxPendingBytes = 512 * 1024
18-
ptyMaxBufferedBytes = 8 * 1024 * 1024
19-
ptyReaderStallTimeout = 10 * time.Second
20-
tabActorStallTimeout = 10 * time.Second
21-
ptyRestartMax = 5
22-
ptyRestartWindow = time.Minute
12+
ptyFlushInactiveMultiplier = 4
13+
ptyFlushInactiveHeavyMultiplier = 8
14+
ptyFlushInactiveVeryHeavyMultiplier = 12
15+
ptyFlushInactiveMaxIntervalCap = 250 * time.Millisecond
16+
ptyHeavyLoadTabThreshold = 4
17+
ptyVeryHeavyLoadTabThreshold = 8
18+
ptyLoadSampleInterval = 100 * time.Millisecond
19+
ptyFlushChunkSize = 32 * 1024
20+
ptyReadBufferSize = 32 * 1024
21+
ptyReadQueueSize = 64
22+
ptyFrameInterval = time.Second / 60
23+
ptyMaxPendingBytes = 512 * 1024
24+
ptyMaxBufferedBytes = 8 * 1024 * 1024
25+
ptyReaderStallTimeout = 10 * time.Second
26+
tabActorStallTimeout = 10 * time.Second
27+
ptyRestartMax = 5
28+
ptyRestartWindow = time.Minute
2329

2430
// Backpressure thresholds (inspired by tmux's TTY_BLOCK_START/STOP)
2531
// When pending output exceeds this, we throttle rendering frequency

internal/ui/center/model_pty_lifecycle.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,20 @@ func (m *Model) startPTYReader(wtID string, tab *Tab) tea.Cmd {
2323
if tab.readerActive {
2424
if tab.ptyMsgCh == nil || tab.readerCancel == nil {
2525
tab.readerActive = false
26+
atomic.StoreUint32(&tab.readerActiveState, 0)
2627
} else {
2728
tab.mu.Unlock()
2829
return nil
2930
}
3031
}
3132
if tab.Agent == nil || tab.Agent.Terminal == nil || tab.Agent.Terminal.IsClosed() {
3233
tab.readerActive = false
34+
atomic.StoreUint32(&tab.readerActiveState, 0)
3335
tab.mu.Unlock()
3436
return nil
3537
}
3638
tab.readerActive = true
39+
atomic.StoreUint32(&tab.readerActiveState, 1)
3740
tab.ptyRestartBackoff = 0
3841
atomic.StoreInt64(&tab.ptyHeartbeat, time.Now().UnixNano())
3942

@@ -91,6 +94,7 @@ func (m *Model) stopPTYReader(tab *Tab) {
9194
tab.readerCancel = nil
9295
}
9396
tab.readerActive = false
97+
atomic.StoreUint32(&tab.readerActiveState, 0)
9498
tab.ptyMsgCh = nil
9599
tab.mu.Unlock()
96100
atomic.StoreInt64(&tab.ptyHeartbeat, 0)
@@ -102,6 +106,7 @@ func (m *Model) markPTYReaderStopped(tab *Tab) {
102106
}
103107
tab.mu.Lock()
104108
tab.readerActive = false
109+
atomic.StoreUint32(&tab.readerActiveState, 0)
105110
tab.ptyMsgCh = nil
106111
tab.mu.Unlock()
107112
atomic.StoreInt64(&tab.ptyHeartbeat, 0)

internal/ui/center/model_pty_reader.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,31 @@ func (m *Model) flushTiming(tab *Tab, active bool) (time.Duration, time.Duration
1515
quiet := ptyFlushQuiet
1616
maxInterval := ptyFlushMaxInterval
1717

18+
// Snapshot terminal state under lock, then release before the load-sampling call.
1819
tab.mu.Lock()
20+
altScreen := tab.Terminal != nil && tab.Terminal.AltScreen
21+
var termWidth, termHeight int
22+
var pendingLen int
23+
if tab.Terminal != nil {
24+
termWidth = tab.Terminal.Width
25+
termHeight = tab.Terminal.Height
26+
pendingLen = len(tab.pendingOutput)
27+
}
28+
tab.mu.Unlock()
29+
1930
// Only use slower Alt timing for true AltScreen mode (full-screen TUIs).
2031
// SyncActive (DEC 2026) already handles partial updates via screen snapshots,
2132
// so we don't need slower flush timing - it just makes streaming text feel laggy.
22-
if tab.Terminal != nil && tab.Terminal.AltScreen {
33+
if altScreen {
2334
quiet = ptyFlushQuietAlt
2435
maxInterval = ptyFlushMaxAlt
2536
}
2637

2738
// Apply backpressure when pending output exceeds threshold
2839
// This prevents renderer thrashing during heavy output (like builds)
29-
if tab.Terminal != nil && len(tab.pendingOutput) > 0 {
30-
threshold := ptyBackpressureMultiplier * tab.Terminal.Width * tab.Terminal.Height
31-
if len(tab.pendingOutput) > threshold {
40+
if pendingLen > 0 {
41+
threshold := ptyBackpressureMultiplier * termWidth * termHeight
42+
if pendingLen > threshold {
3243
// Under backpressure: use minimum flush interval
3344
if quiet < ptyBackpressureFlushFloor {
3445
quiet = ptyBackpressureFlushFloor
@@ -38,11 +49,26 @@ func (m *Model) flushTiming(tab *Tab, active bool) (time.Duration, time.Duration
3849
}
3950
}
4051
}
41-
tab.mu.Unlock()
4252

4353
if !active {
44-
quiet *= ptyFlushInactiveMultiplier
45-
maxInterval *= ptyFlushInactiveMultiplier
54+
busyCount := m.busyPTYTabCount(time.Now())
55+
var mult time.Duration
56+
switch {
57+
case busyCount >= ptyVeryHeavyLoadTabThreshold:
58+
mult = ptyFlushInactiveVeryHeavyMultiplier
59+
case busyCount >= ptyHeavyLoadTabThreshold:
60+
mult = ptyFlushInactiveHeavyMultiplier
61+
default:
62+
mult = ptyFlushInactiveMultiplier
63+
}
64+
quiet *= mult
65+
maxInterval *= mult
66+
if quiet > ptyFlushInactiveMaxIntervalCap {
67+
quiet = ptyFlushInactiveMaxIntervalCap
68+
}
69+
if maxInterval > ptyFlushInactiveMaxIntervalCap {
70+
maxInterval = ptyFlushInactiveMaxIntervalCap
71+
}
4672
if maxInterval < quiet {
4773
maxInterval = quiet
4874
}
@@ -51,6 +77,28 @@ func (m *Model) flushTiming(tab *Tab, active bool) (time.Duration, time.Duration
5177
return quiet, maxInterval
5278
}
5379

80+
func (m *Model) busyPTYTabCount(now time.Time) int {
81+
// Return cached count if sampled within ptyLoadSampleInterval (100ms)
82+
if !m.flushLoadSampleAt.IsZero() && now.Sub(m.flushLoadSampleAt) < ptyLoadSampleInterval {
83+
return m.cachedBusyTabCount
84+
}
85+
count := 0
86+
for _, tabs := range m.tabsByWorkspace {
87+
for _, tab := range tabs {
88+
if tab == nil || tab.isClosed() {
89+
continue
90+
}
91+
busy := atomic.LoadUint32(&tab.readerActiveState) == 1 || len(tab.pendingOutput) > 0
92+
if busy {
93+
count++
94+
}
95+
}
96+
}
97+
m.flushLoadSampleAt = now
98+
m.cachedBusyTabCount = count
99+
return count
100+
}
101+
54102
func (m *Model) forwardPTYMsgs(msgCh <-chan tea.Msg) {
55103
for msg := range msgCh {
56104
if msg == nil {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package center
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/andyrewlee/amux/internal/vterm"
8+
)
9+
10+
func TestFlushTiming_InactiveBackpressureRespectsHardCap(t *testing.T) {
11+
m := newTestModel()
12+
13+
ws := newTestWorkspace("ws-main", "/repo/ws-main")
14+
wsID := string(ws.ID())
15+
width, height := 80, 24
16+
tab := &Tab{
17+
ID: TabID("tab-main"),
18+
Workspace: ws,
19+
Terminal: vterm.New(width, height),
20+
pendingOutput: make([]byte, ptyBackpressureMultiplier*width*height+1),
21+
}
22+
m.tabsByWorkspace[wsID] = []*Tab{tab}
23+
24+
heavyWS := newTestWorkspace("ws-heavy", "/repo/ws-heavy")
25+
heavyWSID := string(heavyWS.ID())
26+
busyTabs := make([]*Tab, 0, ptyVeryHeavyLoadTabThreshold)
27+
for i := 0; i < ptyVeryHeavyLoadTabThreshold; i++ {
28+
busyTabs = append(busyTabs, &Tab{
29+
ID: TabID(fmt.Sprintf("tab-busy-%d", i)),
30+
Workspace: heavyWS,
31+
pendingOutput: []byte{'x'},
32+
})
33+
}
34+
m.tabsByWorkspace[heavyWSID] = busyTabs
35+
36+
quiet, maxInterval := m.flushTiming(tab, false)
37+
if quiet != ptyFlushInactiveMaxIntervalCap {
38+
t.Fatalf("expected quiet=%s under extreme load cap, got %s", ptyFlushInactiveMaxIntervalCap, quiet)
39+
}
40+
if maxInterval != ptyFlushInactiveMaxIntervalCap {
41+
t.Fatalf("expected maxInterval=%s under extreme load cap, got %s", ptyFlushInactiveMaxIntervalCap, maxInterval)
42+
}
43+
}

internal/ui/center/model_workspace_rebind.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ func (m *Model) RebindWorkspaceID(previous, current *data.Workspace) tea.Cmd {
2222

2323
oldTabs, ok := m.tabsByWorkspace[oldID]
2424
if !ok || len(oldTabs) == 0 {
25-
if m.workspace != nil && string(m.workspace.ID()) == oldID {
26-
m.workspace = current
25+
if m.workspace != nil && m.workspaceID() == oldID {
26+
m.setWorkspace(current)
2727
}
2828
return nil
2929
}
@@ -54,8 +54,8 @@ func (m *Model) RebindWorkspaceID(previous, current *data.Workspace) tea.Cmd {
5454
}
5555
delete(m.activeTabByWorkspace, oldID)
5656

57-
if m.workspace != nil && string(m.workspace.ID()) == oldID {
58-
m.workspace = current
57+
if m.workspace != nil && m.workspaceID() == oldID {
58+
m.setWorkspace(current)
5959
}
6060

6161
var cmds []tea.Cmd

internal/ui/dashboard/dashboard_render.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ func (m *Model) renderRow(row Row, selected bool) string {
3838
status := ""
3939
statusText := ""
4040
dirty := false
41-
main := m.getMainWorkspace(row.Project)
41+
active := row.ActivityWorkspaceID != "" && m.activeWorkspaceIDs[row.ActivityWorkspaceID]
42+
main := row.MainWorkspace
4243
if main != nil {
4344
if m.deletingWorkspaces[main.Root] {
4445
frame := common.SpinnerFrame(m.spinnerFrame)
@@ -58,13 +59,13 @@ func (m *Model) renderRow(row Row, selected bool) string {
5859
Bold(true).
5960
Foreground(common.ColorForeground).
6061
Background(common.ColorSelection)
61-
if m.isProjectActive(row.Project) {
62+
if active {
6263
style = style.Foreground(common.ColorPrimary)
6364
}
64-
} else if m.isProjectActive(row.Project) {
65+
} else if active {
6566
style = m.styles.ActiveWorkspace.PaddingLeft(0)
6667
}
67-
style = applyDirtyForeground(style, dirty, m.isProjectActive(row.Project), selected)
68+
style = applyDirtyForeground(style, dirty, active, selected)
6869

6970
// Reserve space for delete icon to keep status aligned
7071
deleteSlot := " "
@@ -107,7 +108,7 @@ func (m *Model) renderRow(row Row, selected bool) string {
107108
} else if _, ok := m.creatingWorkspaces[row.Workspace.Root]; ok {
108109
frame := common.SpinnerFrame(m.spinnerFrame)
109110
statusText = m.styles.StatusPending.Render(frame + " creating")
110-
} else if row.Workspace != nil && m.activeWorkspaceIDs[string(row.Workspace.ID())] {
111+
} else if row.ActivityWorkspaceID != "" && m.activeWorkspaceIDs[row.ActivityWorkspaceID] {
111112
// Active agents - color change only, no spinner
112113
working = true
113114
} else if s, ok := m.statusCache[row.Workspace.Root]; ok && !s.Clean {

internal/ui/dashboard/dashboard_state.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,17 @@ func (m *Model) rebuildRows() {
6868

6969
for i := range m.projects {
7070
project := &m.projects[i]
71+
mainWS := m.getMainWorkspace(project)
72+
mainWSID := ""
73+
if mainWS != nil {
74+
mainWSID = string(mainWS.ID())
75+
}
7176

7277
m.rows = append(m.rows, Row{
73-
Type: RowProject,
74-
Project: project,
78+
Type: RowProject,
79+
Project: project,
80+
ActivityWorkspaceID: mainWSID,
81+
MainWorkspace: mainWS,
7582
})
7683

7784
for _, ws := range m.sortedWorkspaces(project) {
@@ -81,9 +88,10 @@ func (m *Model) rebuildRows() {
8188
}
8289

8390
m.rows = append(m.rows, Row{
84-
Type: RowWorkspace,
85-
Project: project,
86-
Workspace: ws,
91+
Type: RowWorkspace,
92+
Project: project,
93+
Workspace: ws,
94+
ActivityWorkspaceID: string(ws.ID()),
8795
})
8896
}
8997

0 commit comments

Comments
 (0)