Skip to content

Commit b451c89

Browse files
andyrewleeclaude
andcommitted
Extract shared PTY reader infrastructure into internal/ui/common/
Move duplicated RunPTYReader, SendPTYMsg, ForwardPTYMsgs, SafeClose, and SelectionState from center and sidebar into common/, using callback-based parameterization so neither side's message types change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 035580b commit b451c89

12 files changed

Lines changed: 313 additions & 390 deletions

internal/ui/center/model.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,6 @@ func generateTabID() TabID {
3131
return TabID(fmt.Sprintf("tab-%d", id))
3232
}
3333

34-
// SelectionState tracks mouse selection state for copy/paste
35-
type SelectionState struct {
36-
Active bool // Selection in progress (mouse button down)?
37-
StartX int // Start column (terminal coordinates)
38-
StartLine int // Start row (absolute line number, 0 = first scrollback line)
39-
EndX int // End column
40-
EndLine int // End row (absolute line number)
41-
}
42-
4334
// Tab represents a single tab in the center pane
4435
type Tab struct {
4536
ID TabID // Unique identifier that survives slice reordering
@@ -72,7 +63,7 @@ type Tab struct {
7263
ptyMsgCh chan tea.Msg
7364
readerCancel chan struct{}
7465
// Mouse selection state
75-
Selection SelectionState
66+
Selection common.SelectionState
7667
selectionScroll common.SelectionScrollState
7768
selectionLastTermX int
7869

internal/ui/center/model_input.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
138138
if tab.Terminal != nil {
139139
tab.Terminal.ClearSelection()
140140
}
141-
tab.Selection = SelectionState{}
141+
tab.Selection = common.SelectionState{}
142142
tab.selectionScroll.Reset()
143143
tab.mu.Unlock()
144144
}

internal/ui/center/model_input_mouse.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ func (m *Model) updateMouseClick(msg tea.MouseClickMsg) (*Model, tea.Cmd) {
6060
if tab.Terminal != nil {
6161
tab.Terminal.ClearSelection()
6262
}
63-
tab.Selection = SelectionState{}
63+
tab.Selection = common.SelectionState{}
6464
tab.selectionScroll.Reset()
6565
if inBounds && tab.Terminal != nil {
6666
absLine := tab.Terminal.ScreenYToAbsoluteLine(termY)
67-
tab.Selection = SelectionState{
67+
tab.Selection = common.SelectionState{
6868
Active: true,
6969
StartX: termX,
7070
StartLine: absLine,

internal/ui/center/model_pty_lifecycle.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
tea "charm.land/bubbletea/v2"
88

99
"github.com/andyrewlee/amux/internal/safego"
10+
"github.com/andyrewlee/amux/internal/ui/common"
1011
)
1112

1213
func (m *Model) startPTYReader(wtID string, tab *Tab) tea.Cmd {
@@ -41,7 +42,7 @@ func (m *Model) startPTYReader(wtID string, tab *Tab) tea.Cmd {
4142
atomic.StoreInt64(&tab.ptyHeartbeat, time.Now().UnixNano())
4243

4344
if tab.readerCancel != nil {
44-
safeClose(tab.readerCancel)
45+
common.SafeClose(tab.readerCancel)
4546
}
4647
tab.readerCancel = make(chan struct{})
4748
tab.ptyMsgCh = make(chan tea.Msg, ptyReadQueueSize)
@@ -54,21 +55,23 @@ func (m *Model) startPTYReader(wtID string, tab *Tab) tea.Cmd {
5455

5556
safego.Go("center.pty_reader", func() {
5657
defer m.markPTYReaderStopped(tab)
57-
runPTYReader(term, msgCh, cancel, wtID, tabID, &tab.ptyHeartbeat)
58+
common.RunPTYReader(term, msgCh, cancel, &tab.ptyHeartbeat, common.PTYReaderConfig{
59+
Label: "center.pty_read_loop",
60+
ReadBufferSize: ptyReadBufferSize,
61+
ReadQueueSize: ptyReadQueueSize,
62+
FrameInterval: ptyFrameInterval,
63+
MaxPendingBytes: ptyMaxPendingBytes,
64+
}, common.PTYMsgFactory{
65+
Output: func(data []byte) tea.Msg { return PTYOutput{WorkspaceID: wtID, TabID: tabID, Data: data} },
66+
Stopped: func(err error) tea.Msg { return PTYStopped{WorkspaceID: wtID, TabID: tabID, Err: err} },
67+
})
5868
})
5969
safego.Go("center.pty_forward", func() {
6070
m.forwardPTYMsgs(msgCh)
6171
})
6272
return nil
6373
}
6474

65-
func safeClose(ch chan struct{}) {
66-
defer func() {
67-
_ = recover()
68-
}()
69-
close(ch)
70-
}
71-
7275
func (m *Model) resizePTY(tab *Tab, rows, cols int) {
7376
if tab == nil || tab.Agent == nil || tab.Agent.Terminal == nil {
7477
return
@@ -90,7 +93,7 @@ func (m *Model) stopPTYReader(tab *Tab) {
9093
}
9194
tab.mu.Lock()
9295
if tab.readerCancel != nil {
93-
safeClose(tab.readerCancel)
96+
common.SafeClose(tab.readerCancel)
9497
tab.readerCancel = nil
9598
}
9699
tab.readerActive = false
Lines changed: 18 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
package center
22

33
import (
4-
"io"
54
"sync/atomic"
65
"time"
76

87
tea "charm.land/bubbletea/v2"
98

10-
appPty "github.com/andyrewlee/amux/internal/pty"
11-
"github.com/andyrewlee/amux/internal/safego"
9+
"github.com/andyrewlee/amux/internal/ui/common"
1210
)
1311

1412
func (m *Model) flushTiming(tab *Tab, active bool) (time.Duration, time.Duration) {
@@ -100,172 +98,23 @@ func (m *Model) busyPTYTabCount(now time.Time) int {
10098
}
10199

102100
func (m *Model) forwardPTYMsgs(msgCh <-chan tea.Msg) {
103-
for msg := range msgCh {
104-
if msg == nil {
105-
continue
106-
}
107-
out, ok := msg.(PTYOutput)
108-
if !ok {
109-
if m.msgSink != nil {
110-
m.msgSink(msg)
111-
}
112-
continue
113-
}
114-
115-
merged := out
116-
for {
117-
select {
118-
case next, ok := <-msgCh:
119-
if !ok {
120-
if m.msgSink != nil && len(merged.Data) > 0 {
121-
m.msgSink(merged)
122-
}
123-
return
124-
}
125-
if next == nil {
126-
continue
127-
}
128-
if nextOut, ok := next.(PTYOutput); ok &&
129-
nextOut.WorkspaceID == merged.WorkspaceID &&
130-
nextOut.TabID == merged.TabID {
131-
merged.Data = append(merged.Data, nextOut.Data...)
132-
if len(merged.Data) >= ptyMaxPendingBytes {
133-
if m.msgSink != nil && len(merged.Data) > 0 {
134-
m.msgSink(merged)
135-
}
136-
merged.Data = nil
137-
}
138-
continue
139-
}
140-
if m.msgSink != nil && len(merged.Data) > 0 {
141-
m.msgSink(merged)
142-
}
143-
if m.msgSink != nil {
144-
m.msgSink(next)
145-
}
146-
goto nextMsg
147-
default:
148-
if m.msgSink != nil && len(merged.Data) > 0 {
149-
m.msgSink(merged)
150-
}
151-
goto nextMsg
152-
}
153-
}
154-
nextMsg:
155-
}
156-
}
157-
158-
func runPTYReader(term *appPty.Terminal, msgCh chan tea.Msg, cancel <-chan struct{}, wtID string, tabID TabID, heartbeat *int64) {
159-
// Ensure msgCh is always closed even if we panic, so forwardPTYMsgs doesn't block forever.
160-
// The inner recover() catches double-close panics from existing close(msgCh) calls.
161-
defer func() {
162-
defer func() { _ = recover() }()
163-
close(msgCh)
164-
}()
165-
166-
if term == nil {
167-
return
168-
}
169-
beat := func() {
170-
if heartbeat != nil {
171-
atomic.StoreInt64(heartbeat, time.Now().UnixNano())
172-
}
173-
}
174-
beat()
175-
176-
dataCh := make(chan []byte, ptyReadQueueSize)
177-
errCh := make(chan error, 1)
178-
179-
safego.Go("center.pty_read_loop", func() {
180-
buf := make([]byte, ptyReadBufferSize)
181-
for {
182-
n, err := term.Read(buf)
183-
if err != nil {
184-
select {
185-
case errCh <- err:
186-
default:
187-
}
188-
close(dataCh)
189-
return
190-
}
191-
if n == 0 {
192-
continue
193-
}
194-
beat()
195-
chunk := make([]byte, n)
196-
copy(chunk, buf[:n])
197-
select {
198-
case dataCh <- chunk:
199-
case <-cancel:
200-
return
101+
common.ForwardPTYMsgs(msgCh, m.msgSink, common.OutputMerger{
102+
ExtractData: func(msg tea.Msg) ([]byte, bool) {
103+
if out, ok := msg.(PTYOutput); ok {
104+
return out.Data, true
201105
}
202-
}
106+
return nil, false
107+
},
108+
CanMerge: func(cur, next tea.Msg) bool {
109+
c, _ := cur.(PTYOutput)
110+
n, _ := next.(PTYOutput)
111+
return c.WorkspaceID == n.WorkspaceID && c.TabID == n.TabID
112+
},
113+
Build: func(first tea.Msg, data []byte) tea.Msg {
114+
out, _ := first.(PTYOutput)
115+
out.Data = data
116+
return out
117+
},
118+
MaxPending: ptyMaxPendingBytes,
203119
})
204-
205-
ticker := time.NewTicker(ptyFrameInterval)
206-
defer ticker.Stop()
207-
208-
var pending []byte
209-
var stoppedErr error
210-
211-
for {
212-
select {
213-
case <-cancel:
214-
close(msgCh)
215-
return
216-
case err := <-errCh:
217-
beat()
218-
stoppedErr = err
219-
case data, ok := <-dataCh:
220-
beat()
221-
if !ok {
222-
if len(pending) > 0 {
223-
if !sendPTYMsg(msgCh, cancel, PTYOutput{WorkspaceID: wtID, TabID: tabID, Data: pending}) {
224-
close(msgCh)
225-
return
226-
}
227-
}
228-
if stoppedErr == nil {
229-
stoppedErr = io.EOF
230-
}
231-
sendPTYMsg(msgCh, cancel, PTYStopped{WorkspaceID: wtID, TabID: tabID, Err: stoppedErr})
232-
close(msgCh)
233-
return
234-
}
235-
pending = append(pending, data...)
236-
if len(pending) >= ptyMaxPendingBytes {
237-
if !sendPTYMsg(msgCh, cancel, PTYOutput{WorkspaceID: wtID, TabID: tabID, Data: pending}) {
238-
close(msgCh)
239-
return
240-
}
241-
pending = nil
242-
}
243-
case <-ticker.C:
244-
beat()
245-
if len(pending) > 0 {
246-
if !sendPTYMsg(msgCh, cancel, PTYOutput{WorkspaceID: wtID, TabID: tabID, Data: pending}) {
247-
close(msgCh)
248-
return
249-
}
250-
pending = nil
251-
}
252-
if stoppedErr != nil {
253-
sendPTYMsg(msgCh, cancel, PTYStopped{WorkspaceID: wtID, TabID: tabID, Err: stoppedErr})
254-
close(msgCh)
255-
return
256-
}
257-
}
258-
}
259-
}
260-
261-
func sendPTYMsg(msgCh chan tea.Msg, cancel <-chan struct{}, msg tea.Msg) bool {
262-
if msgCh == nil {
263-
return false
264-
}
265-
select {
266-
case <-cancel:
267-
return false
268-
case msgCh <- msg:
269-
return true
270-
}
271120
}

internal/ui/center/tab_actor.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/andyrewlee/amux/internal/logging"
1010
"github.com/andyrewlee/amux/internal/perf"
11+
"github.com/andyrewlee/amux/internal/ui/common"
1112
)
1213

1314
type tabEventKind int
@@ -160,7 +161,7 @@ func (m *Model) handleTabEvent(ev tabEvent) {
160161
if tab.Terminal != nil {
161162
tab.Terminal.ClearSelection()
162163
}
163-
tab.Selection = SelectionState{}
164+
tab.Selection = common.SelectionState{}
164165
tab.selectionScroll.Reset()
165166
tab.mu.Unlock()
166167
case tabEventSelectionClearAndNotify:
@@ -175,7 +176,7 @@ func (m *Model) handleTabEvent(ev tabEvent) {
175176
if tab.Terminal != nil {
176177
tab.Terminal.ClearSelection()
177178
}
178-
tab.Selection = SelectionState{}
179+
tab.Selection = common.SelectionState{}
179180
tab.selectionScroll.Reset()
180181
tab.mu.Unlock()
181182
if ev.notifyCopy && text != "" && m.msgSink != nil {
@@ -199,11 +200,11 @@ func (m *Model) handleTabEvent(ev tabEvent) {
199200
if tab.Terminal != nil {
200201
tab.Terminal.ClearSelection()
201202
}
202-
tab.Selection = SelectionState{}
203+
tab.Selection = common.SelectionState{}
203204
tab.selectionScroll.Reset()
204205
if ev.inBounds && tab.Terminal != nil {
205206
absLine := tab.Terminal.ScreenYToAbsoluteLine(ev.termY)
206-
tab.Selection = SelectionState{
207+
tab.Selection = common.SelectionState{
207208
Active: true,
208209
StartX: ev.termX,
209210
StartLine: absLine,

0 commit comments

Comments
 (0)