detached 37fcb4a

refactor(daemon): split daemon implementation files

commit
37fcb4aedf92e84a70de76137ca8198991380f9e
author
brice · · 2026-07-03 05:08 UTC
committer
brice · · 2026-07-03 07:33 UTC
parent
c2f37ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1993 -1729
A internal/usecase/daemon/client.go +353 -0
@@ -0,0 +1,353 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "errors"
30+ "sync"
31+ "time"
32+
33+ "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
35+ scopy "github.com/bnema/vev/internal/usecase/copy"
36+ "github.com/bnema/vev/internal/usecase/keys"
37+ "github.com/bnema/vev/internal/usecase/mouse"
38+ "github.com/bnema/vev/internal/usecase/picker"
39+ "github.com/bnema/vev/pkg/renderer"
40+)
41+
42+type attachedClient struct {
43+ tr ports.Transport
44+ rend *renderer.Renderer
45+ size domain.Size
46+ keys *keys.Router
47+ sessMu sync.Mutex
48+ sess *session
49+ copyMu sync.Mutex
50+ copyMode *scopy.Mode
51+ copyPending []byte
52+ copyESC pendingByteTimer
53+ copyFeedback string
54+ pickerMu sync.Mutex
55+ picker *picker.Model
56+ pickerPreview *tab
57+ pickerPending []byte
58+ pickerESC pendingByteTimer
59+ mouseScan mouse.Scanner
60+ lastCursor cursorOut
61+ sendMu sync.Mutex
62+}
63+
64+type cursorOut struct {
65+ valid bool
66+ hidden bool
67+ row int
68+ col int
69+ style int
70+ hasStyle bool
71+}
72+
73+type pendingByteTimer struct {
74+ timer ports.Timer
75+ done chan struct{}
76+}
77+
78+func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
79+ p.timer = clock.NewTimer(delay)
80+ p.done = make(chan struct{})
81+ go func(timer ports.Timer, done <-chan struct{}) {
82+ select {
83+ case <-timer.C():
84+ case <-done:
85+ return
86+ }
87+ onFire(timer)
88+ }(p.timer, p.done)
89+}
90+
91+func (p *pendingByteTimer) stop() {
92+ if p.timer != nil {
93+ p.timer.Stop()
94+ p.timer = nil
95+ }
96+ if p.done != nil {
97+ close(p.done)
98+ p.done = nil
99+ }
100+}
101+
102+func (ac *attachedClient) currentSession() *session {
103+ ac.sessMu.Lock()
104+ defer ac.sessMu.Unlock()
105+ return ac.sess
106+}
107+
108+func (ac *attachedClient) setSession(sess *session) {
109+ ac.sessMu.Lock()
110+ defer ac.sessMu.Unlock()
111+ ac.sess = sess
112+}
113+
114+func (ac *attachedClient) copyModeActive() bool {
115+ ac.copyMu.Lock()
116+ defer ac.copyMu.Unlock()
117+ return ac.copyMode != nil
118+}
119+
120+func (ac *attachedClient) pickerActive() bool {
121+ ac.pickerMu.Lock()
122+ defer ac.pickerMu.Unlock()
123+ return ac.picker != nil
124+}
125+
126+// send serialises a frame onto the client's transport.
127+func (ac *attachedClient) send(f ports.Frame) error {
128+ ac.sendMu.Lock()
129+ defer ac.sendMu.Unlock()
130+ return ac.tr.Send(f)
131+}
132+
133+// boundedSend sends f to ac with a deadline watchdog: if the send (including
134+// waiting on sendMu behind a wedged paint) does not complete within
135+// detachNotifyTimeout, the transport is force-closed, failing the in-flight
136+// write. Detach/kill/shutdown paths use this so they are never gated on a
137+// client that has stopped draining its socket.
138+func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
139+ _ = d.boundedSendErr(ac, f)
140+}
141+
142+func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
143+ timer := d.clock.NewTimer(detachNotifyTimeout)
144+ result := make(chan error, 1)
145+ go func() {
146+ result <- ac.send(f)
147+ }()
148+ select {
149+ case err := <-result:
150+ timer.Stop()
151+ return err
152+ case <-timer.C():
153+ select {
154+ case err := <-result:
155+ return err
156+ default:
157+ }
158+ _ = ac.tr.Close()
159+ return errors.New("send timed out")
160+ }
161+}
162+
163+// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
164+// goroutine and then closes the transport. Session teardown (killSession) must
165+// complete regardless of client state, so the notice is both asynchronous and
166+// deadline-bounded; Serve waits for pending notices before force-closing
167+// connections so a graceful notice is not raced by the hard close.
168+func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
169+ done := make(chan struct{})
170+ d.mu.Lock()
171+ // Prune completed entries so the slice stays bounded by the number of
172+ // notifications actually in flight.
173+ kept := d.notifies[:0]
174+ for _, c := range d.notifies {
175+ select {
176+ case <-c:
177+ default:
178+ kept = append(kept, c)
179+ }
180+ }
181+ d.notifies = append(kept, done)
182+ d.mu.Unlock()
183+
184+ go func() {
185+ defer close(done)
186+ d.boundedSend(ac, frameDetached(reason))
187+ _ = ac.tr.Close()
188+ }()
189+}
190+
191+// waitNotifies blocks until every Detached notification in flight at the time
192+// of the call has completed. Each is deadline-bounded (boundedSend), so this
193+// wait is bounded too.
194+func (d *Daemon) waitNotifies() {
195+ d.mu.Lock()
196+ snapshot := make([]chan struct{}, len(d.notifies))
197+ copy(snapshot, d.notifies)
198+ d.mu.Unlock()
199+ for _, c := range snapshot {
200+ <-c
201+ }
202+}
203+
204+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
205+ ac := &attachedClient{
206+ tr: tr,
207+ rend: renderer.New(renderer.Capabilities{}),
208+ size: sz,
209+ }
210+ ac.setSession(sess)
211+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
212+ sess.mu.Lock()
213+ old := sess.client
214+ sess.client = ac
215+ sess.mu.Unlock()
216+
217+ if old != nil {
218+ d.unregisterPreview(old)
219+ old.setSession(nil)
220+ // Async + bounded: a dead or wedged old client must not stall the new
221+ // client's handshake.
222+ d.notifyDetachedAsync(old, ports.ReasonDetach)
223+ }
224+ return ac
225+}
226+
227+// firstPaint guarantees the freshly attached client sees the full screen: if
228+// the tab size differs from the client's it resizes first (which paints a
229+// full redraw), otherwise it forces a full paint against the fresh renderer.
230+func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
231+ tb := sess.activeTab()
232+ if tb == nil {
233+ return
234+ }
235+ tb.mu.Lock()
236+ wsz := tb.size
237+ tb.mu.Unlock()
238+
239+ if clientSize.Valid() && wsz != tabSize(clientSize) {
240+ d.resize(sess, ac, clientSize)
241+ return
242+ }
243+ d.paint(sess, ac, true)
244+}
245+
246+// runConnLoop is the per-connection input router: it pumps client messages
247+// until detach, EOF, or a transport error.
248+func (d *Daemon) runConnLoop(ac *attachedClient) {
249+ for {
250+ sess := ac.currentSession()
251+ if sess == nil {
252+ return
253+ }
254+ f, err := ac.tr.Recv()
255+ if err != nil {
256+ d.clientGone(sess, ac, false)
257+ return
258+ }
259+ sess = ac.currentSession()
260+ if sess == nil {
261+ return
262+ }
263+ switch f.Type {
264+ case ports.MsgInput:
265+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
266+ d.handleInput(sess, ac, in.Data)
267+ }
268+ case ports.MsgResize:
269+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
270+ d.resize(sess, ac, rz.Size)
271+ }
272+ case ports.MsgDetach:
273+ d.clientGone(sess, ac, true)
274+ return
275+ case ports.MsgPing:
276+ _ = ac.send(framePong())
277+ default:
278+ // Unknown/out-of-band client messages are ignored so a newer
279+ // client can add message types without breaking an older daemon.
280+ }
281+ }
282+}
283+
284+// clientGone detaches ac if it is still the session's current client. An
285+// ephemeral session dies with its client; a named one survives headless. When
286+
287+func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
288+ if !sess.detachIfCurrent(ac) {
289+ return // already displaced by a newer client; nothing to do
290+ }
291+ d.unregisterPreview(ac)
292+ if explicit {
293+ // Synchronous so the ack is delivered before the transport closes
294+ // (the client is actively awaiting it), but deadline-bounded so a
295+ // wedged client cannot pin this conn handler and hang Serve's
296+ // connWg.Wait.
297+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
298+ }
299+ _ = ac.tr.Close()
300+ if sess.ephemeral {
301+ d.killSession(sess, ports.ReasonSessionKilled)
302+ }
303+}
304+
305+// detachIfCurrent clears the client iff ac is the current one, reporting
306+
307+func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
308+ if !sz.Valid() {
309+ return
310+ }
311+ tbSize := tabSize(sz)
312+ ac.size = sz
313+
314+ sess.mu.Lock()
315+ tabs := append([]*tab(nil), sess.tabs...)
316+ sess.mu.Unlock()
317+ if len(tabs) == 0 {
318+ return
319+ }
320+
321+ for _, tb := range tabs {
322+ if err := tb.pty.Resize(tbSize); err != nil {
323+ d.log.Warn("pty resize failed", "err", err, "session", sess.name)
324+ }
325+ tb.mu.Lock()
326+ tb.screen.Resize(tbSize.Cols, tbSize.Rows)
327+ tb.size = tbSize
328+ tb.mu.Unlock()
329+ }
330+ d.paint(sess, ac, true)
331+}
332+
333+// detachOnSendError drops a client whose transport failed. Like every other
334+// detach path, losing the client kills an ephemeral session (its lifetime is
335+// its client's); a named session keeps running headless.
336+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
337+ if sess.detachIfCurrent(ac) {
338+ d.unregisterPreview(ac)
339+ _ = ac.tr.Close()
340+ d.log.Warn("detached client after send error", "session", sess.name)
341+ if sess.ephemeral {
342+ d.killSession(sess, ports.ReasonSessionKilled)
343+ }
344+ }
345+}
346+
347+// killSession removes a session and tears down its resources. It is
348+// idempotent: only the caller that wins the registry delete acts. When the
349+// registry empties it marks the daemon closing (atomically with the
350+// empty-check, under d.mu) and signals shutdown.
351+//
352+// Teardown ordering matters: context cancel, pty.Close, and the done signal
353+// run first and unconditionally — never gated behind a client send. The
@@ -0,0 +1,353 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "errors"
30+ "sync"
31+ "time"
32+
33+ "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
35+ scopy "github.com/bnema/vev/internal/usecase/copy"
36+ "github.com/bnema/vev/internal/usecase/keys"
37+ "github.com/bnema/vev/internal/usecase/mouse"
38+ "github.com/bnema/vev/internal/usecase/picker"
39+ "github.com/bnema/vev/pkg/renderer"
40+)
41+
42+type attachedClient struct {
43+ tr ports.Transport
44+ rend *renderer.Renderer
45+ size domain.Size
46+ keys *keys.Router
47+ sessMu sync.Mutex
48+ sess *session
49+ copyMu sync.Mutex
50+ copyMode *scopy.Mode
51+ copyPending []byte
52+ copyESC pendingByteTimer
53+ copyFeedback string
54+ pickerMu sync.Mutex
55+ picker *picker.Model
56+ pickerPreview *tab
57+ pickerPending []byte
58+ pickerESC pendingByteTimer
59+ mouseScan mouse.Scanner
60+ lastCursor cursorOut
61+ sendMu sync.Mutex
62+}
63+
64+type cursorOut struct {
65+ valid bool
66+ hidden bool
67+ row int
68+ col int
69+ style int
70+ hasStyle bool
71+}
72+
73+type pendingByteTimer struct {
74+ timer ports.Timer
75+ done chan struct{}
76+}
77+
78+func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
79+ p.timer = clock.NewTimer(delay)
80+ p.done = make(chan struct{})
81+ go func(timer ports.Timer, done <-chan struct{}) {
82+ select {
83+ case <-timer.C():
84+ case <-done:
85+ return
86+ }
87+ onFire(timer)
88+ }(p.timer, p.done)
89+}
90+
91+func (p *pendingByteTimer) stop() {
92+ if p.timer != nil {
93+ p.timer.Stop()
94+ p.timer = nil
95+ }
96+ if p.done != nil {
97+ close(p.done)
98+ p.done = nil
99+ }
100+}
101+
102+func (ac *attachedClient) currentSession() *session {
103+ ac.sessMu.Lock()
104+ defer ac.sessMu.Unlock()
105+ return ac.sess
106+}
107+
108+func (ac *attachedClient) setSession(sess *session) {
109+ ac.sessMu.Lock()
110+ defer ac.sessMu.Unlock()
111+ ac.sess = sess
112+}
113+
114+func (ac *attachedClient) copyModeActive() bool {
115+ ac.copyMu.Lock()
116+ defer ac.copyMu.Unlock()
117+ return ac.copyMode != nil
118+}
119+
120+func (ac *attachedClient) pickerActive() bool {
121+ ac.pickerMu.Lock()
122+ defer ac.pickerMu.Unlock()
123+ return ac.picker != nil
124+}
125+
126+// send serialises a frame onto the client's transport.
127+func (ac *attachedClient) send(f ports.Frame) error {
128+ ac.sendMu.Lock()
129+ defer ac.sendMu.Unlock()
130+ return ac.tr.Send(f)
131+}
132+
133+// boundedSend sends f to ac with a deadline watchdog: if the send (including
134+// waiting on sendMu behind a wedged paint) does not complete within
135+// detachNotifyTimeout, the transport is force-closed, failing the in-flight
136+// write. Detach/kill/shutdown paths use this so they are never gated on a
137+// client that has stopped draining its socket.
138+func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
139+ _ = d.boundedSendErr(ac, f)
140+}
141+
142+func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
143+ timer := d.clock.NewTimer(detachNotifyTimeout)
144+ result := make(chan error, 1)
145+ go func() {
146+ result <- ac.send(f)
147+ }()
148+ select {
149+ case err := <-result:
150+ timer.Stop()
151+ return err
152+ case <-timer.C():
153+ select {
154+ case err := <-result:
155+ return err
156+ default:
157+ }
158+ _ = ac.tr.Close()
159+ return errors.New("send timed out")
160+ }
161+}
162+
163+// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
164+// goroutine and then closes the transport. Session teardown (killSession) must
165+// complete regardless of client state, so the notice is both asynchronous and
166+// deadline-bounded; Serve waits for pending notices before force-closing
167+// connections so a graceful notice is not raced by the hard close.
168+func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
169+ done := make(chan struct{})
170+ d.mu.Lock()
171+ // Prune completed entries so the slice stays bounded by the number of
172+ // notifications actually in flight.
173+ kept := d.notifies[:0]
174+ for _, c := range d.notifies {
175+ select {
176+ case <-c:
177+ default:
178+ kept = append(kept, c)
179+ }
180+ }
181+ d.notifies = append(kept, done)
182+ d.mu.Unlock()
183+
184+ go func() {
185+ defer close(done)
186+ d.boundedSend(ac, frameDetached(reason))
187+ _ = ac.tr.Close()
188+ }()
189+}
190+
191+// waitNotifies blocks until every Detached notification in flight at the time
192+// of the call has completed. Each is deadline-bounded (boundedSend), so this
193+// wait is bounded too.
194+func (d *Daemon) waitNotifies() {
195+ d.mu.Lock()
196+ snapshot := make([]chan struct{}, len(d.notifies))
197+ copy(snapshot, d.notifies)
198+ d.mu.Unlock()
199+ for _, c := range snapshot {
200+ <-c
201+ }
202+}
203+
204+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
205+ ac := &attachedClient{
206+ tr: tr,
207+ rend: renderer.New(renderer.Capabilities{}),
208+ size: sz,
209+ }
210+ ac.setSession(sess)
211+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
212+ sess.mu.Lock()
213+ old := sess.client
214+ sess.client = ac
215+ sess.mu.Unlock()
216+
217+ if old != nil {
218+ d.unregisterPreview(old)
219+ old.setSession(nil)
220+ // Async + bounded: a dead or wedged old client must not stall the new
221+ // client's handshake.
222+ d.notifyDetachedAsync(old, ports.ReasonDetach)
223+ }
224+ return ac
225+}
226+
227+// firstPaint guarantees the freshly attached client sees the full screen: if
228+// the tab size differs from the client's it resizes first (which paints a
229+// full redraw), otherwise it forces a full paint against the fresh renderer.
230+func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
231+ tb := sess.activeTab()
232+ if tb == nil {
233+ return
234+ }
235+ tb.mu.Lock()
236+ wsz := tb.size
237+ tb.mu.Unlock()
238+
239+ if clientSize.Valid() && wsz != tabSize(clientSize) {
240+ d.resize(sess, ac, clientSize)
241+ return
242+ }
243+ d.paint(sess, ac, true)
244+}
245+
246+// runConnLoop is the per-connection input router: it pumps client messages
247+// until detach, EOF, or a transport error.
248+func (d *Daemon) runConnLoop(ac *attachedClient) {
249+ for {
250+ sess := ac.currentSession()
251+ if sess == nil {
252+ return
253+ }
254+ f, err := ac.tr.Recv()
255+ if err != nil {
256+ d.clientGone(sess, ac, false)
257+ return
258+ }
259+ sess = ac.currentSession()
260+ if sess == nil {
261+ return
262+ }
263+ switch f.Type {
264+ case ports.MsgInput:
265+ if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
266+ d.handleInput(sess, ac, in.Data)
267+ }
268+ case ports.MsgResize:
269+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
270+ d.resize(sess, ac, rz.Size)
271+ }
272+ case ports.MsgDetach:
273+ d.clientGone(sess, ac, true)
274+ return
275+ case ports.MsgPing:
276+ _ = ac.send(framePong())
277+ default:
278+ // Unknown/out-of-band client messages are ignored so a newer
279+ // client can add message types without breaking an older daemon.
280+ }
281+ }
282+}
283+
284+// clientGone detaches ac if it is still the session's current client. An
285+// ephemeral session dies with its client; a named one survives headless. When
286+
287+func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
288+ if !sess.detachIfCurrent(ac) {
289+ return // already displaced by a newer client; nothing to do
290+ }
291+ d.unregisterPreview(ac)
292+ if explicit {
293+ // Synchronous so the ack is delivered before the transport closes
294+ // (the client is actively awaiting it), but deadline-bounded so a
295+ // wedged client cannot pin this conn handler and hang Serve's
296+ // connWg.Wait.
297+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
298+ }
299+ _ = ac.tr.Close()
300+ if sess.ephemeral {
301+ d.killSession(sess, ports.ReasonSessionKilled)
302+ }
303+}
304+
305+// detachIfCurrent clears the client iff ac is the current one, reporting
306+
307+func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
308+ if !sz.Valid() {
309+ return
310+ }
311+ tbSize := tabSize(sz)
312+ ac.size = sz
313+
314+ sess.mu.Lock()
315+ tabs := append([]*tab(nil), sess.tabs...)
316+ sess.mu.Unlock()
317+ if len(tabs) == 0 {
318+ return
319+ }
320+
321+ for _, tb := range tabs {
322+ if err := tb.pty.Resize(tbSize); err != nil {
323+ d.log.Warn("pty resize failed", "err", err, "session", sess.name)
324+ }
325+ tb.mu.Lock()
326+ tb.screen.Resize(tbSize.Cols, tbSize.Rows)
327+ tb.size = tbSize
328+ tb.mu.Unlock()
329+ }
330+ d.paint(sess, ac, true)
331+}
332+
333+// detachOnSendError drops a client whose transport failed. Like every other
334+// detach path, losing the client kills an ephemeral session (its lifetime is
335+// its client's); a named session keeps running headless.
336+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
337+ if sess.detachIfCurrent(ac) {
338+ d.unregisterPreview(ac)
339+ _ = ac.tr.Close()
340+ d.log.Warn("detached client after send error", "session", sess.name)
341+ if sess.ephemeral {
342+ d.killSession(sess, ports.ReasonSessionKilled)
343+ }
344+ }
345+}
346+
347+// killSession removes a session and tears down its resources. It is
348+// idempotent: only the caller that wins the registry delete acts. When the
349+// registry empties it marks the daemon closing (atomically with the
350+// empty-check, under d.mu) and signals shutdown.
351+//
352+// Teardown ordering matters: context cancel, pty.Close, and the done signal
353+// run first and unconditionally — never gated behind a client send. The
A internal/usecase/daemon/copymode.go +253 -0
@@ -0,0 +1,253 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/internal/ports"
32+ scopy "github.com/bnema/vev/internal/usecase/copy"
33+ "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/pkg/renderer"
35+)
36+
37+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
38+ tb := sess.activeTab()
39+ if tb == nil {
40+ return
41+ }
42+
43+ tb.mu.Lock()
44+ ac.copyMu.Lock()
45+ if ac.copyMode == nil {
46+ ac.copyMu.Unlock()
47+ tb.mu.Unlock()
48+ return
49+ }
50+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
51+ if delta > 0 && ac.copyMode.AtBottom(snap) {
52+ ac.copyMode = nil
53+ ac.copyMu.Unlock()
54+ tb.mu.Unlock()
55+ d.paint(sess, ac, true)
56+ return
57+ }
58+ ac.copyMode.Move(snap, delta)
59+ exit := delta > 0 && ac.copyMode.AtBottom(snap)
60+ if exit {
61+ ac.copyMode = nil
62+ }
63+ ac.copyMu.Unlock()
64+ tb.mu.Unlock()
65+
66+ d.paint(sess, ac, true)
67+}
68+
69+func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
70+ tb := sess.activeTab()
71+ if tb == nil {
72+ return
73+ }
74+ tb.mu.Lock()
75+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
76+ tb.mu.Unlock()
77+ ac.copyMu.Lock()
78+ ac.copyMode = scopy.NewMode(snap)
79+ ac.copyMu.Unlock()
80+ d.paint(sess, ac, true)
81+}
82+
83+func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
84+ sess := ac.currentSession()
85+ if sess == nil {
86+ return
87+ }
88+ tb := sess.activeTab()
89+ if tb == nil {
90+ return
91+ }
92+
93+ tb.mu.Lock()
94+ ac.copyMu.Lock()
95+ if ac.copyMode == nil {
96+ ac.copyPending = nil
97+ d.stopCopyPendingTimerLocked(ac)
98+ ac.copyMu.Unlock()
99+ tb.mu.Unlock()
100+ return
101+ }
102+ if len(ac.copyPending) > 0 {
103+ d.stopCopyPendingTimerLocked(ac)
104+ combined := make([]byte, 0, len(ac.copyPending)+len(data))
105+ combined = append(combined, ac.copyPending...)
106+ combined = append(combined, data...)
107+ data = combined
108+ ac.copyPending = nil
109+ }
110+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
111+ changed := false
112+ copyOut := false
113+ exit := false
114+ for i := 0; i < len(data); i++ {
115+ switch data[i] {
116+ case 'j':
117+ ac.copyMode.Move(snap, 1)
118+ changed = true
119+ case 'k':
120+ ac.copyMode.Move(snap, -1)
121+ changed = true
122+ case 'g':
123+ ac.copyMode.Top(snap)
124+ changed = true
125+ case 'G':
126+ ac.copyMode.Bottom(snap)
127+ changed = true
128+ case ' ':
129+ ac.copyMode.ToggleSelection()
130+ changed = true
131+ case '\r', '\n', 'y':
132+ copyOut = true
133+ exit = true
134+ case 'q', 0x03, 0x1b:
135+ if data[i] == 0x1b {
136+ tail := data[i:]
137+ consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
138+ if ok {
139+ i += consumed - 1
140+ changed = true
141+ continue
142+ }
143+ if len(tail) == 1 {
144+ d.retainCopyESCLocked(ac)
145+ break
146+ }
147+ if isCopyEscapePrefix(tail) {
148+ ac.copyPending = append(ac.copyPending[:0], tail...)
149+ break
150+ }
151+ }
152+ exit = true
153+ }
154+ }
155+ text := ""
156+ if copyOut {
157+ text = ac.copyMode.SelectedText(snap)
158+ }
159+ if exit {
160+ d.stopCopyPendingTimerLocked(ac)
161+ ac.copyMode = nil
162+ }
163+ ac.copyMu.Unlock()
164+ tb.mu.Unlock()
165+
166+ if copyOut && text != "" {
167+ chunks := scopy.OSC52(text)
168+ for _, chunk := range chunks {
169+ if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
170+ d.detachOnSendError(sess, ac)
171+ return
172+ }
173+ }
174+ ac.copyMu.Lock()
175+ if len(chunks) > 0 {
176+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
177+ } else {
178+ ac.copyFeedback = "selection too large to copy"
179+ }
180+ ac.copyMu.Unlock()
181+ }
182+ if exit {
183+ d.paint(sess, ac, true)
184+ return
185+ }
186+ if changed {
187+ d.paint(sess, ac, true)
188+ }
189+}
190+
191+func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
192+ mode := ac.copyMode
193+ ac.copyPending = append(ac.copyPending[:0], keys.ESC)
194+ ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195+ ac.copyMu.Lock()
196+ if ac.copyESC.timer != timer {
197+ ac.copyMu.Unlock()
198+ return
199+ }
200+ ac.copyPending = nil
201+ ac.copyESC.timer = nil
202+ ac.copyESC.done = nil
203+ if ac.copyMode != mode || ac.copyMode == nil {
204+ ac.copyMu.Unlock()
205+ return
206+ }
207+ ac.copyMode = nil
208+ ac.copyMu.Unlock()
209+
210+ if sess := ac.currentSession(); sess != nil {
211+ d.paint(sess, ac, true)
212+ }
213+ })
214+}
215+
216+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
217+ ac.copyESC.stop()
218+}
219+
220+func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
221+ if len(data) >= 3 && data[1] == '[' {
222+ switch data[2] {
223+ case 'A':
224+ m.Move(snap, -1)
225+ return 3, true
226+ case 'B':
227+ m.Move(snap, 1)
228+ return 3, true
229+ }
230+ }
231+ if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
232+ switch data[2] {
233+ case '5':
234+ m.Page(snap, -1)
235+ return 4, true
236+ case '6':
237+ m.Page(snap, 1)
238+ return 4, true
239+ }
240+ }
241+ return 0, false
242+}
243+
244+func isCopyEscapePrefix(data []byte) bool {
245+ return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
246+ len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
247+}
248+
249+func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
250+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
251+ frame := mode.Render(snap)
252+ return frame, []renderer.Damage{renderer.FullRedraw()}
253+}
@@ -0,0 +1,253 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/internal/ports"
32+ scopy "github.com/bnema/vev/internal/usecase/copy"
33+ "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/pkg/renderer"
35+)
36+
37+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
38+ tb := sess.activeTab()
39+ if tb == nil {
40+ return
41+ }
42+
43+ tb.mu.Lock()
44+ ac.copyMu.Lock()
45+ if ac.copyMode == nil {
46+ ac.copyMu.Unlock()
47+ tb.mu.Unlock()
48+ return
49+ }
50+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
51+ if delta > 0 && ac.copyMode.AtBottom(snap) {
52+ ac.copyMode = nil
53+ ac.copyMu.Unlock()
54+ tb.mu.Unlock()
55+ d.paint(sess, ac, true)
56+ return
57+ }
58+ ac.copyMode.Move(snap, delta)
59+ exit := delta > 0 && ac.copyMode.AtBottom(snap)
60+ if exit {
61+ ac.copyMode = nil
62+ }
63+ ac.copyMu.Unlock()
64+ tb.mu.Unlock()
65+
66+ d.paint(sess, ac, true)
67+}
68+
69+func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
70+ tb := sess.activeTab()
71+ if tb == nil {
72+ return
73+ }
74+ tb.mu.Lock()
75+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
76+ tb.mu.Unlock()
77+ ac.copyMu.Lock()
78+ ac.copyMode = scopy.NewMode(snap)
79+ ac.copyMu.Unlock()
80+ d.paint(sess, ac, true)
81+}
82+
83+func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
84+ sess := ac.currentSession()
85+ if sess == nil {
86+ return
87+ }
88+ tb := sess.activeTab()
89+ if tb == nil {
90+ return
91+ }
92+
93+ tb.mu.Lock()
94+ ac.copyMu.Lock()
95+ if ac.copyMode == nil {
96+ ac.copyPending = nil
97+ d.stopCopyPendingTimerLocked(ac)
98+ ac.copyMu.Unlock()
99+ tb.mu.Unlock()
100+ return
101+ }
102+ if len(ac.copyPending) > 0 {
103+ d.stopCopyPendingTimerLocked(ac)
104+ combined := make([]byte, 0, len(ac.copyPending)+len(data))
105+ combined = append(combined, ac.copyPending...)
106+ combined = append(combined, data...)
107+ data = combined
108+ ac.copyPending = nil
109+ }
110+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
111+ changed := false
112+ copyOut := false
113+ exit := false
114+ for i := 0; i < len(data); i++ {
115+ switch data[i] {
116+ case 'j':
117+ ac.copyMode.Move(snap, 1)
118+ changed = true
119+ case 'k':
120+ ac.copyMode.Move(snap, -1)
121+ changed = true
122+ case 'g':
123+ ac.copyMode.Top(snap)
124+ changed = true
125+ case 'G':
126+ ac.copyMode.Bottom(snap)
127+ changed = true
128+ case ' ':
129+ ac.copyMode.ToggleSelection()
130+ changed = true
131+ case '\r', '\n', 'y':
132+ copyOut = true
133+ exit = true
134+ case 'q', 0x03, 0x1b:
135+ if data[i] == 0x1b {
136+ tail := data[i:]
137+ consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
138+ if ok {
139+ i += consumed - 1
140+ changed = true
141+ continue
142+ }
143+ if len(tail) == 1 {
144+ d.retainCopyESCLocked(ac)
145+ break
146+ }
147+ if isCopyEscapePrefix(tail) {
148+ ac.copyPending = append(ac.copyPending[:0], tail...)
149+ break
150+ }
151+ }
152+ exit = true
153+ }
154+ }
155+ text := ""
156+ if copyOut {
157+ text = ac.copyMode.SelectedText(snap)
158+ }
159+ if exit {
160+ d.stopCopyPendingTimerLocked(ac)
161+ ac.copyMode = nil
162+ }
163+ ac.copyMu.Unlock()
164+ tb.mu.Unlock()
165+
166+ if copyOut && text != "" {
167+ chunks := scopy.OSC52(text)
168+ for _, chunk := range chunks {
169+ if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
170+ d.detachOnSendError(sess, ac)
171+ return
172+ }
173+ }
174+ ac.copyMu.Lock()
175+ if len(chunks) > 0 {
176+ ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
177+ } else {
178+ ac.copyFeedback = "selection too large to copy"
179+ }
180+ ac.copyMu.Unlock()
181+ }
182+ if exit {
183+ d.paint(sess, ac, true)
184+ return
185+ }
186+ if changed {
187+ d.paint(sess, ac, true)
188+ }
189+}
190+
191+func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
192+ mode := ac.copyMode
193+ ac.copyPending = append(ac.copyPending[:0], keys.ESC)
194+ ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195+ ac.copyMu.Lock()
196+ if ac.copyESC.timer != timer {
197+ ac.copyMu.Unlock()
198+ return
199+ }
200+ ac.copyPending = nil
201+ ac.copyESC.timer = nil
202+ ac.copyESC.done = nil
203+ if ac.copyMode != mode || ac.copyMode == nil {
204+ ac.copyMu.Unlock()
205+ return
206+ }
207+ ac.copyMode = nil
208+ ac.copyMu.Unlock()
209+
210+ if sess := ac.currentSession(); sess != nil {
211+ d.paint(sess, ac, true)
212+ }
213+ })
214+}
215+
216+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
217+ ac.copyESC.stop()
218+}
219+
220+func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
221+ if len(data) >= 3 && data[1] == '[' {
222+ switch data[2] {
223+ case 'A':
224+ m.Move(snap, -1)
225+ return 3, true
226+ case 'B':
227+ m.Move(snap, 1)
228+ return 3, true
229+ }
230+ }
231+ if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
232+ switch data[2] {
233+ case '5':
234+ m.Page(snap, -1)
235+ return 4, true
236+ case '6':
237+ m.Page(snap, 1)
238+ return 4, true
239+ }
240+ }
241+ return 0, false
242+}
243+
244+func isCopyEscapePrefix(data []byte) bool {
245+ return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
246+ len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
247+}
248+
249+func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
250+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
251+ frame := mode.Render(snap)
252+ return frame, []renderer.Damage{renderer.FullRedraw()}
253+}
M internal/usecase/daemon/daemon.go +0 -1729
@@ -26,27 +26,16 @@
2626 package daemon
2727
2828 import (
29- "bytes"
3029 "context"
3130 "errors"
32- "fmt"
3331 "log/slog"
3432 "os"
3533 "sort"
36- "strconv"
37- "strings"
3834 "sync"
3935 "time"
4036
4137 "github.com/bnema/vev/internal/domain"
4238 "github.com/bnema/vev/internal/ports"
43- scopy "github.com/bnema/vev/internal/usecase/copy"
44- "github.com/bnema/vev/internal/usecase/keys"
45- "github.com/bnema/vev/internal/usecase/mouse"
46- "github.com/bnema/vev/internal/usecase/picker"
47- "github.com/bnema/vev/internal/usecase/ui"
48- "github.com/bnema/vev/pkg/renderer"
49- "github.com/bnema/vev/pkg/vt"
5039 )
5140
5241 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -58,9 +47,6 @@ const (
5847 maxSyncUpdateDuration = 500 * time.Millisecond
5948 )
6049
61-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
62-
63-// debounceInterval is kept as a test/back-compat alias for the initial delay.
6450 const debounceInterval = minDebounceInterval
6551
6652 // ptyReadBufSize is the PTY reader's read buffer.
@@ -78,8 +64,6 @@ const detachNotifyTimeout = time.Second
7864 // defaultSize is used when a client's Hello carries no valid dimensions.
7965 var defaultSize = domain.Size{Cols: 80, Rows: 24}
8066
81-// Daemon is vev's server-side multiplexer. Construct it with New and drive it
82-// with Serve.
8367 type Daemon struct {
8468 mu sync.Mutex
8569 sessions map[domain.SessionID]*session
@@ -121,209 +105,7 @@ type Daemon struct {
121105 }
122106
123107 // session is a single multiplexed session. It owns one or more full-screen
124-// tabs and at most one attached client.
125-type session struct {
126- id domain.SessionID
127- name string
128- ephemeral bool
129-
130- ctx context.Context
131- cancel context.CancelFunc
132-
133- mu sync.Mutex // guards tabs, active, and client
134- tabs []*tab
135- active int
136- client *attachedClient
137- cwd string
138-}
139-
140-// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
141-// applies back-pressure by collapsing (a full buffer means "a render is
142-// already pending"), never by blocking the reader.
143-type tab struct {
144- pty ports.PTY
145- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
146- screen *vt.Screen
147- scrollback *scopy.Scrollback
148- dirty chan struct{}
149- flush chan struct{}
150- syncGen uint64
151- // previewClient tracks the one client currently previewing this tab in the picker.
152- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
153- previewClient *attachedClient
154- size domain.Size
155- ctx context.Context
156- cancel context.CancelFunc
157-}
158-
159-// attachedClient is a client currently attached to a session's tab. rend is
160-// its private renderer shadow (so each client gets output minimised against
161-// what it has actually seen). sendMu serialises the two senders — the render
162-// scheduler and the connection handler — so the transport's single-writer
163-// contract holds and Draw→Send stays atomic (correct output ordering).
164-type attachedClient struct {
165- tr ports.Transport
166- rend *renderer.Renderer
167- size domain.Size
168- keys *keys.Router
169- sessMu sync.Mutex
170- sess *session
171- copyMu sync.Mutex
172- copyMode *scopy.Mode
173- copyPending []byte
174- copyESC pendingByteTimer
175- copyFeedback string
176- pickerMu sync.Mutex
177- picker *picker.Model
178- pickerPreview *tab
179- pickerPending []byte
180- pickerESC pendingByteTimer
181- mouseScan mouse.Scanner
182- lastCursor cursorOut
183- sendMu sync.Mutex
184-}
185-
186-type cursorOut struct {
187- valid bool
188- hidden bool
189- row int
190- col int
191- style int
192- hasStyle bool
193-}
194-
195-type pendingByteTimer struct {
196- timer ports.Timer
197- done chan struct{}
198-}
199-
200-func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
201- p.timer = clock.NewTimer(delay)
202- p.done = make(chan struct{})
203- go func(timer ports.Timer, done <-chan struct{}) {
204- select {
205- case <-timer.C():
206- case <-done:
207- return
208- }
209- onFire(timer)
210- }(p.timer, p.done)
211-}
212-
213-func (p *pendingByteTimer) stop() {
214- if p.timer != nil {
215- p.timer.Stop()
216- p.timer = nil
217- }
218- if p.done != nil {
219- close(p.done)
220- p.done = nil
221- }
222-}
223-
224-func (ac *attachedClient) currentSession() *session {
225- ac.sessMu.Lock()
226- defer ac.sessMu.Unlock()
227- return ac.sess
228-}
229-
230-func (ac *attachedClient) setSession(sess *session) {
231- ac.sessMu.Lock()
232- defer ac.sessMu.Unlock()
233- ac.sess = sess
234-}
235-
236-func (ac *attachedClient) copyModeActive() bool {
237- ac.copyMu.Lock()
238- defer ac.copyMu.Unlock()
239- return ac.copyMode != nil
240-}
241-
242-func (ac *attachedClient) pickerActive() bool {
243- ac.pickerMu.Lock()
244- defer ac.pickerMu.Unlock()
245- return ac.picker != nil
246-}
247-
248-// send serialises a frame onto the client's transport.
249-func (ac *attachedClient) send(f ports.Frame) error {
250- ac.sendMu.Lock()
251- defer ac.sendMu.Unlock()
252- return ac.tr.Send(f)
253-}
254-
255-// boundedSend sends f to ac with a deadline watchdog: if the send (including
256-// waiting on sendMu behind a wedged paint) does not complete within
257-// detachNotifyTimeout, the transport is force-closed, failing the in-flight
258-// write. Detach/kill/shutdown paths use this so they are never gated on a
259-// client that has stopped draining its socket.
260-func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
261- _ = d.boundedSendErr(ac, f)
262-}
263108
264-func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
265- timer := d.clock.NewTimer(detachNotifyTimeout)
266- result := make(chan error, 1)
267- go func() {
268- result <- ac.send(f)
269- }()
270- select {
271- case err := <-result:
272- timer.Stop()
273- return err
274- case <-timer.C():
275- select {
276- case err := <-result:
277- return err
278- default:
279- }
280- _ = ac.tr.Close()
281- return errors.New("send timed out")
282- }
283-}
284-
285-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
286-// goroutine and then closes the transport. Session teardown (killSession) must
287-// complete regardless of client state, so the notice is both asynchronous and
288-// deadline-bounded; Serve waits for pending notices before force-closing
289-// connections so a graceful notice is not raced by the hard close.
290-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
291- done := make(chan struct{})
292- d.mu.Lock()
293- // Prune completed entries so the slice stays bounded by the number of
294- // notifications actually in flight.
295- kept := d.notifies[:0]
296- for _, c := range d.notifies {
297- select {
298- case <-c:
299- default:
300- kept = append(kept, c)
301- }
302- }
303- d.notifies = append(kept, done)
304- d.mu.Unlock()
305-
306- go func() {
307- defer close(done)
308- d.boundedSend(ac, frameDetached(reason))
309- _ = ac.tr.Close()
310- }()
311-}
312-
313-// waitNotifies blocks until every Detached notification in flight at the time
314-// of the call has completed. Each is deadline-bounded (boundedSend), so this
315-// wait is bounded too.
316-func (d *Daemon) waitNotifies() {
317- d.mu.Lock()
318- snapshot := make([]chan struct{}, len(d.notifies))
319- copy(snapshot, d.notifies)
320- d.mu.Unlock()
321- for _, c := range snapshot {
322- <-c
323- }
324-}
325-
326-// Option customises a Daemon at construction.
327109 type Option func(*Daemon)
328110
329111 // WithShell overrides the command (and its args) each session spawns. The
@@ -612,1514 +394,3 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
612394 return nil, nil, &protoErr{ports.ErrInternal, "unknown intent"}
613395 }
614396 }
615-
616-// createSessionLocked spawns a PTY-backed session and starts its reader and
617-// scheduler goroutines. Caller holds d.mu.
618-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
619- tbSize := tabSize(sz)
620- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
621- if err != nil {
622- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
623- }
624-
625- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
626- d.nextID++
627-
628- tb := newTab(pty, tbSize)
629- sctx, cancel := context.WithCancel(d.serveCtx)
630- tb.ctx, tb.cancel = context.WithCancel(sctx)
631- sess := &session{
632- id: id,
633- name: name,
634- ephemeral: ephemeral,
635- ctx: sctx,
636- cancel: cancel,
637- tabs: []*tab{tb},
638- cwd: cwd,
639- }
640- d.sessions[id] = sess
641- d.startTabGoroutines(sess, tb)
642- return sess, nil
643-}
644-
645-func (d *Daemon) createTab(sess *session, sz domain.Size) error {
646- tbSize := tabSize(sz)
647- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
648- if err != nil {
649- return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
650- }
651- tb := newTab(pty, tbSize)
652- tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
653- sess.mu.Lock()
654- sess.tabs = append(sess.tabs, tb)
655- sess.active = len(sess.tabs) - 1
656- sess.mu.Unlock()
657- d.startTabGoroutines(sess, tb)
658- return nil
659-}
660-
661-func newTab(pty ports.PTY, sz domain.Size) *tab {
662- sb := scopy.NewScrollback(defaultScrollbackRows)
663- screen := vt.NewScreen(sz.Cols, sz.Rows)
664- screen.OnLineEvicted = sb.Append
665- return &tab{
666- pty: pty,
667- screen: screen,
668- scrollback: sb,
669- dirty: make(chan struct{}, 1),
670- flush: make(chan struct{}, 1),
671- size: sz,
672- }
673-}
674-
675-func tabSize(clientSize domain.Size) domain.Size {
676- if !clientSize.Valid() {
677- clientSize = defaultSize
678- }
679- rows := max(clientSize.Rows-1, 1)
680- return domain.Size{Cols: clientSize.Cols, Rows: rows}
681-}
682-
683-func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
684- d.sessWg.Add(2)
685- go d.ptyReader(sess, tb)
686- go d.scheduler(sess, tb)
687-}
688-
689-// attachClient makes ac the session's current client, displacing any prior one
690-// (which is notified with ReasonDetach and disconnected — its own conn handler
691-// then unwinds without killing the session, since it is no longer current).
692-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
693- ac := &attachedClient{
694- tr: tr,
695- rend: renderer.New(renderer.Capabilities{}),
696- size: sz,
697- }
698- ac.setSession(sess)
699- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
700- sess.mu.Lock()
701- old := sess.client
702- sess.client = ac
703- sess.mu.Unlock()
704-
705- if old != nil {
706- d.unregisterPreview(old)
707- old.setSession(nil)
708- // Async + bounded: a dead or wedged old client must not stall the new
709- // client's handshake.
710- d.notifyDetachedAsync(old, ports.ReasonDetach)
711- }
712- return ac
713-}
714-
715-// firstPaint guarantees the freshly attached client sees the full screen: if
716-// the tab size differs from the client's it resizes first (which paints a
717-// full redraw), otherwise it forces a full paint against the fresh renderer.
718-func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
719- tb := sess.activeTab()
720- if tb == nil {
721- return
722- }
723- tb.mu.Lock()
724- wsz := tb.size
725- tb.mu.Unlock()
726-
727- if clientSize.Valid() && wsz != tabSize(clientSize) {
728- d.resize(sess, ac, clientSize)
729- return
730- }
731- d.paint(sess, ac, true)
732-}
733-
734-// runConnLoop is the per-connection input router: it pumps client messages
735-// until detach, EOF, or a transport error.
736-func (d *Daemon) runConnLoop(ac *attachedClient) {
737- for {
738- sess := ac.currentSession()
739- if sess == nil {
740- return
741- }
742- f, err := ac.tr.Recv()
743- if err != nil {
744- d.clientGone(sess, ac, false)
745- return
746- }
747- sess = ac.currentSession()
748- if sess == nil {
749- return
750- }
751- switch f.Type {
752- case ports.MsgInput:
753- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
754- d.handleInput(sess, ac, in.Data)
755- }
756- case ports.MsgResize:
757- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
758- d.resize(sess, ac, rz.Size)
759- }
760- case ports.MsgDetach:
761- d.clientGone(sess, ac, true)
762- return
763- case ports.MsgPing:
764- _ = ac.send(framePong())
765- default:
766- // Unknown/out-of-band client messages are ignored so a newer
767- // client can add message types without breaking an older daemon.
768- }
769- }
770-}
771-
772-// clientGone detaches ac if it is still the session's current client. An
773-// ephemeral session dies with its client; a named one survives headless. When
774-// explicit (a MsgDetach), the client is acked with ReasonDetach.
775-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
776- if !sess.detachIfCurrent(ac) {
777- return // already displaced by a newer client; nothing to do
778- }
779- d.unregisterPreview(ac)
780- if explicit {
781- // Synchronous so the ack is delivered before the transport closes
782- // (the client is actively awaiting it), but deadline-bounded so a
783- // wedged client cannot pin this conn handler and hang Serve's
784- // connWg.Wait.
785- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
786- }
787- _ = ac.tr.Close()
788- if sess.ephemeral {
789- d.killSession(sess, ports.ReasonSessionKilled)
790- }
791-}
792-
793-// detachIfCurrent clears the client iff ac is the current one, reporting
794-// whether it acted.
795-func (s *session) detachIfCurrent(ac *attachedClient) bool {
796- s.mu.Lock()
797- defer s.mu.Unlock()
798- if s.client == ac {
799- s.client = nil
800- ac.setSession(nil)
801- return true
802- }
803- return false
804-}
805-
806-func (s *session) activeTab() *tab {
807- s.mu.Lock()
808- defer s.mu.Unlock()
809- if s.active < 0 || s.active >= len(s.tabs) {
810- return nil
811- }
812- return s.tabs[s.active]
813-}
814-
815-func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
816- ac.mouseScan.Scan(data,
817- func(ev mouse.Event) { d.handleMouse(ac, ev) },
818- func(b []byte) {
819- if ac.pickerActive() {
820- d.handlePickerInput(ac, b)
821- return
822- }
823- if ac.copyModeActive() {
824- d.handleCopyInput(ac, b)
825- return
826- }
827- ac.keys.Route(b)
828- },
829- )
830-}
831-
832-func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
833- if ac.pickerActive() {
834- return
835- }
836- sess := ac.currentSession()
837- if sess == nil {
838- return
839- }
840- tb := sess.activeTab()
841- if tb == nil {
842- return
843- }
844-
845- if ac.copyModeActive() {
846- switch ev.Button {
847- case mouse.WheelUp:
848- d.copyWheel(sess, ac, -3)
849- case mouse.WheelDown:
850- d.copyWheel(sess, ac, 3)
851- }
852- return
853- }
854-
855- tb.mu.Lock()
856- childRows := tb.screen.Frame.Height
857- mouseMode, mouseSGR := tb.screen.MouseMode()
858- altScreen := tb.screen.AltScreenActive()
859- tb.mu.Unlock()
860-
861- if mouseMode != 0 {
862- if !mouseSGR || ev.Row >= childRows {
863- return
864- }
865- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
866- return
867- }
868-
869- switch ev.Button {
870- case mouse.WheelUp:
871- if altScreen {
872- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
873- return
874- }
875- d.enterCopyMode(sess, ac)
876- d.copyWheel(sess, ac, -3)
877- case mouse.WheelDown:
878- if altScreen {
879- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
880- }
881- }
882-}
883-
884-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
885- tb := sess.activeTab()
886- if tb == nil {
887- return
888- }
889-
890- tb.mu.Lock()
891- ac.copyMu.Lock()
892- if ac.copyMode == nil {
893- ac.copyMu.Unlock()
894- tb.mu.Unlock()
895- return
896- }
897- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
898- if delta > 0 && ac.copyMode.AtBottom(snap) {
899- ac.copyMode = nil
900- ac.copyMu.Unlock()
901- tb.mu.Unlock()
902- d.paint(sess, ac, true)
903- return
904- }
905- ac.copyMode.Move(snap, delta)
906- exit := delta > 0 && ac.copyMode.AtBottom(snap)
907- if exit {
908- ac.copyMode = nil
909- }
910- ac.copyMu.Unlock()
911- tb.mu.Unlock()
912-
913- d.paint(sess, ac, true)
914-}
915-
916-func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
917- tb := sess.activeTab()
918- if tb == nil {
919- return
920- }
921- tb.mu.Lock()
922- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
923- tb.mu.Unlock()
924- ac.copyMu.Lock()
925- ac.copyMode = scopy.NewMode(snap)
926- ac.copyMu.Unlock()
927- d.paint(sess, ac, true)
928-}
929-
930-func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
931- sess := ac.currentSession()
932- if sess == nil {
933- return
934- }
935- tb := sess.activeTab()
936- if tb == nil {
937- return
938- }
939-
940- tb.mu.Lock()
941- ac.copyMu.Lock()
942- if ac.copyMode == nil {
943- ac.copyPending = nil
944- d.stopCopyPendingTimerLocked(ac)
945- ac.copyMu.Unlock()
946- tb.mu.Unlock()
947- return
948- }
949- if len(ac.copyPending) > 0 {
950- d.stopCopyPendingTimerLocked(ac)
951- combined := make([]byte, 0, len(ac.copyPending)+len(data))
952- combined = append(combined, ac.copyPending...)
953- combined = append(combined, data...)
954- data = combined
955- ac.copyPending = nil
956- }
957- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
958- changed := false
959- copyOut := false
960- exit := false
961- for i := 0; i < len(data); i++ {
962- switch data[i] {
963- case 'j':
964- ac.copyMode.Move(snap, 1)
965- changed = true
966- case 'k':
967- ac.copyMode.Move(snap, -1)
968- changed = true
969- case 'g':
970- ac.copyMode.Top(snap)
971- changed = true
972- case 'G':
973- ac.copyMode.Bottom(snap)
974- changed = true
975- case ' ':
976- ac.copyMode.ToggleSelection()
977- changed = true
978- case '\r', '\n', 'y':
979- copyOut = true
980- exit = true
981- case 'q', 0x03, 0x1b:
982- if data[i] == 0x1b {
983- tail := data[i:]
984- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
985- if ok {
986- i += consumed - 1
987- changed = true
988- continue
989- }
990- if len(tail) == 1 {
991- d.retainCopyESCLocked(ac)
992- break
993- }
994- if isCopyEscapePrefix(tail) {
995- ac.copyPending = append(ac.copyPending[:0], tail...)
996- break
997- }
998- }
999- exit = true
1000- }
1001- }
1002- text := ""
1003- if copyOut {
1004- text = ac.copyMode.SelectedText(snap)
1005- }
1006- if exit {
1007- d.stopCopyPendingTimerLocked(ac)
1008- ac.copyMode = nil
1009- }
1010- ac.copyMu.Unlock()
1011- tb.mu.Unlock()
1012-
1013- if copyOut && text != "" {
1014- chunks := scopy.OSC52(text)
1015- for _, chunk := range chunks {
1016- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
1017- d.detachOnSendError(sess, ac)
1018- return
1019- }
1020- }
1021- ac.copyMu.Lock()
1022- if len(chunks) > 0 {
1023- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
1024- } else {
1025- ac.copyFeedback = "selection too large to copy"
1026- }
1027- ac.copyMu.Unlock()
1028- }
1029- if exit {
1030- d.paint(sess, ac, true)
1031- return
1032- }
1033- if changed {
1034- d.paint(sess, ac, true)
1035- }
1036-}
1037-
1038-func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
1039- mode := ac.copyMode
1040- ac.copyPending = append(ac.copyPending[:0], keys.ESC)
1041- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1042- ac.copyMu.Lock()
1043- if ac.copyESC.timer != timer {
1044- ac.copyMu.Unlock()
1045- return
1046- }
1047- ac.copyPending = nil
1048- ac.copyESC.timer = nil
1049- ac.copyESC.done = nil
1050- if ac.copyMode != mode || ac.copyMode == nil {
1051- ac.copyMu.Unlock()
1052- return
1053- }
1054- ac.copyMode = nil
1055- ac.copyMu.Unlock()
1056-
1057- if sess := ac.currentSession(); sess != nil {
1058- d.paint(sess, ac, true)
1059- }
1060- })
1061-}
1062-
1063-func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
1064- ac.copyESC.stop()
1065-}
1066-
1067-func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
1068- if len(data) >= 3 && data[1] == '[' {
1069- switch data[2] {
1070- case 'A':
1071- m.Move(snap, -1)
1072- return 3, true
1073- case 'B':
1074- m.Move(snap, 1)
1075- return 3, true
1076- }
1077- }
1078- if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
1079- switch data[2] {
1080- case '5':
1081- m.Page(snap, -1)
1082- return 4, true
1083- case '6':
1084- m.Page(snap, 1)
1085- return 4, true
1086- }
1087- }
1088- return 0, false
1089-}
1090-
1091-func isCopyEscapePrefix(data []byte) bool {
1092- return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
1093- len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
1094-}
1095-
1096-func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
1097- views, curTab := d.pickerViews(sess)
1098- ac.pickerMu.Lock()
1099- ac.picker = picker.New(views, sess.id, curTab)
1100- ac.pickerPending = nil
1101- ac.pickerPreview = nil
1102- ac.pickerMu.Unlock()
1103- d.registerPreviewForSelection(ac)
1104- d.paint(sess, ac, true)
1105-}
1106-
1107-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
1108- d.mu.Lock()
1109- sessions := make([]*session, 0, len(d.sessions))
1110- for _, s := range d.sessions {
1111- sessions = append(sessions, s)
1112- }
1113- d.mu.Unlock()
1114- sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
1115-
1116- views := make([]picker.SessionView, 0, len(sessions))
1117- curTab := 0
1118- for _, s := range sessions {
1119- s.mu.Lock()
1120- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
1121- for i := range s.tabs {
1122- view.Tabs[i] = strconv.Itoa(i + 1)
1123- }
1124- if s == cur {
1125- curTab = s.active
1126- }
1127- s.mu.Unlock()
1128- views = append(views, view)
1129- }
1130- return views, curTab
1131-}
1132-
1133-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1134- sess := ac.currentSession()
1135- if sess == nil {
1136- return
1137- }
1138- ac.pickerMu.Lock()
1139- if ac.picker == nil {
1140- ac.pickerPending = nil
1141- d.stopPickerPendingTimerLocked(ac)
1142- ac.pickerMu.Unlock()
1143- return
1144- }
1145- if len(ac.pickerPending) > 0 {
1146- d.stopPickerPendingTimerLocked(ac)
1147- combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1148- combined = append(combined, ac.pickerPending...)
1149- combined = append(combined, data...)
1150- data = combined
1151- ac.pickerPending = nil
1152- }
1153- changed := false
1154- exit := false
1155- switchTarget := false
1156- for i := 0; i < len(data); i++ {
1157- switch data[i] {
1158- case 'j':
1159- ac.picker.Down()
1160- changed = true
1161- case 'k':
1162- ac.picker.Up()
1163- changed = true
1164- case '\r', '\n':
1165- switchTarget = true
1166- exit = true
1167- case 'q', 0x03, 0x1b:
1168- if data[i] == 0x1b {
1169- tail := data[i:]
1170- consumed, ok := routePickerEscape(ac.picker, tail)
1171- if ok {
1172- i += consumed - 1
1173- changed = true
1174- continue
1175- }
1176- if len(tail) == 1 {
1177- d.retainPickerESCLocked(ac)
1178- break
1179- }
1180- if isPickerEscapePrefix(tail) {
1181- ac.pickerPending = append(ac.pickerPending[:0], tail...)
1182- break
1183- }
1184- }
1185- exit = true
1186- }
1187- }
1188- var target picker.Target
1189- var ok bool
1190- if switchTarget {
1191- target, ok = ac.picker.Selected()
1192- }
1193- ac.pickerMu.Unlock()
1194-
1195- if changed {
1196- d.registerPreviewForSelection(ac)
1197- }
1198- if exit {
1199- d.closePicker(ac)
1200- }
1201- if switchTarget && ok {
1202- d.switchToTarget(sess, ac, target)
1203- return
1204- }
1205- if exit || changed {
1206- d.paint(sess, ac, true)
1207- }
1208-}
1209-
1210-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
1211- ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
1212- ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1213- ac.pickerMu.Lock()
1214- if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
1215- ac.pickerMu.Unlock()
1216- return
1217- }
1218- ac.pickerPending = nil
1219- ac.pickerESC.timer = nil
1220- ac.pickerESC.done = nil
1221- ac.picker = nil
1222- ac.pickerMu.Unlock()
1223-
1224- d.unregisterPreview(ac)
1225- if sess := ac.currentSession(); sess != nil {
1226- d.paint(sess, ac, true)
1227- }
1228- })
1229-}
1230-
1231-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
1232- ac.pickerESC.stop()
1233-}
1234-
1235-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1236- if len(data) >= 3 && data[1] == '[' {
1237- switch data[2] {
1238- case 'A':
1239- m.Up()
1240- return 3, true
1241- case 'B':
1242- m.Down()
1243- return 3, true
1244- }
1245- }
1246- return 0, false
1247-}
1248-
1249-func isPickerEscapePrefix(data []byte) bool {
1250- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
1251-}
1252-
1253-func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
1254- ac.pickerMu.Lock()
1255- var target picker.Target
1256- var ok bool
1257- if ac.picker != nil {
1258- target, ok = ac.picker.Selected()
1259- }
1260- ac.pickerMu.Unlock()
1261-
1262- var next *tab
1263- if ok {
1264- next = d.tabByTarget(target)
1265- }
1266-
1267- ac.pickerMu.Lock()
1268- old := ac.pickerPreview
1269- if ac.picker == nil {
1270- next = nil
1271- }
1272- ac.pickerPreview = next
1273- ac.pickerMu.Unlock()
1274-
1275- if old != nil && old != next {
1276- old.mu.Lock()
1277- if old.previewClient == ac {
1278- old.previewClient = nil
1279- }
1280- old.mu.Unlock()
1281- }
1282- if next != nil {
1283- next.mu.Lock()
1284- next.previewClient = ac
1285- next.mu.Unlock()
1286-
1287- ac.pickerMu.Lock()
1288- keep := ac.pickerPreview == next
1289- ac.pickerMu.Unlock()
1290- if !keep {
1291- next.mu.Lock()
1292- if next.previewClient == ac {
1293- next.previewClient = nil
1294- }
1295- next.mu.Unlock()
1296- }
1297- }
1298-}
1299-
1300-func (d *Daemon) unregisterPreview(ac *attachedClient) {
1301- ac.pickerMu.Lock()
1302- old := ac.pickerPreview
1303- ac.pickerPreview = nil
1304- ac.pickerMu.Unlock()
1305-
1306- if old != nil {
1307- old.mu.Lock()
1308- if old.previewClient == ac {
1309- old.previewClient = nil
1310- }
1311- old.mu.Unlock()
1312- }
1313-}
1314-
1315-func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1316- tb.mu.Lock()
1317- previewer := tb.previewClient
1318- tb.mu.Unlock()
1319- if previewer == nil {
1320- return
1321- }
1322- cleared := false
1323- previewer.pickerMu.Lock()
1324- if previewer.pickerPreview == tb {
1325- previewer.pickerPreview = nil
1326- cleared = true
1327- }
1328- previewer.pickerMu.Unlock()
1329- tb.mu.Lock()
1330- if tb.previewClient == previewer {
1331- tb.previewClient = nil
1332- }
1333- tb.mu.Unlock()
1334- if cleared {
1335- if sess := previewer.currentSession(); sess != nil {
1336- d.paint(sess, previewer, true)
1337- }
1338- }
1339-}
1340-
1341-func (d *Daemon) closePicker(ac *attachedClient) {
1342- ac.pickerMu.Lock()
1343- ac.picker = nil
1344- ac.pickerPending = nil
1345- d.stopPickerPendingTimerLocked(ac)
1346- ac.pickerMu.Unlock()
1347- d.unregisterPreview(ac)
1348-}
1349-
1350-func (d *Daemon) tabByTarget(target picker.Target) *tab {
1351- d.mu.Lock()
1352- sess := d.sessions[target.Session]
1353- d.mu.Unlock()
1354- if sess == nil {
1355- return nil
1356- }
1357- sess.mu.Lock()
1358- defer sess.mu.Unlock()
1359- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
1360- return nil
1361- }
1362- return sess.tabs[target.TabIndex]
1363-}
1364-
1365-func (d *Daemon) sessionByID(id domain.SessionID) *session {
1366- d.mu.Lock()
1367- defer d.mu.Unlock()
1368- return d.sessions[id]
1369-}
1370-
1371-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
1372- targetSess := d.sessionByID(target.Session)
1373- if targetSess == nil {
1374- d.paint(sess, ac, true)
1375- return
1376- }
1377- if targetSess == sess {
1378- sess.switchTab(target.TabIndex)
1379- d.paint(sess, ac, true)
1380- return
1381- }
1382- old := d.stealClientForTarget(sess, ac, targetSess, target)
1383- if ac.currentSession() != targetSess {
1384- d.paint(sess, ac, true)
1385- return
1386- }
1387- if old != nil && old != ac {
1388- d.unregisterPreview(old)
1389- old.setSession(nil)
1390- d.notifyDetachedAsync(old, ports.ReasonDetach)
1391- }
1392- d.firstPaint(targetSess, ac, ac.size)
1393-}
1394-
1395-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1396- d.mu.Lock()
1397- if d.sessions[target.Session] != targetSess {
1398- d.mu.Unlock()
1399- return nil
1400- }
1401- from.mu.Lock()
1402- if from.client != ac {
1403- from.mu.Unlock()
1404- d.mu.Unlock()
1405- return nil
1406- }
1407- from.client = nil
1408- ac.setSession(nil)
1409- from.mu.Unlock()
1410-
1411- targetSess.mu.Lock()
1412- old := targetSess.client
1413- targetSess.client = ac
1414- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
1415- targetSess.active = target.TabIndex
1416- }
1417- targetSess.mu.Unlock()
1418- ac.setSession(targetSess)
1419- d.mu.Unlock()
1420- return old
1421-}
1422-
1423-type daemonKeyHandler struct {
1424- d *Daemon
1425- ac *attachedClient
1426-}
1427-
1428-func (h daemonKeyHandler) Forward(data []byte) {
1429- sess := h.ac.currentSession()
1430- if sess == nil {
1431- return
1432- }
1433- tb := sess.activeTab()
1434- if tb == nil {
1435- return
1436- }
1437- if _, err := tb.pty.Write(data); err != nil {
1438- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
1439- }
1440-}
1441-
1442-func (h daemonKeyHandler) Action(action keys.Action) {
1443- sess := h.ac.currentSession()
1444- if sess == nil {
1445- return
1446- }
1447- switch action {
1448- case keys.ActionCreateTab:
1449- if err := h.d.createTab(sess, h.ac.size); err != nil {
1450- h.d.log.Error("create tab failed", "err", err, "session", sess.name)
1451- return
1452- }
1453- h.d.paint(sess, h.ac, true)
1454- case keys.ActionNextTab:
1455- if sess.switchRelative(1) {
1456- h.d.paint(sess, h.ac, true)
1457- }
1458- case keys.ActionPreviousTab:
1459- if sess.switchRelative(-1) {
1460- h.d.paint(sess, h.ac, true)
1461- }
1462- case keys.ActionDetach:
1463- h.d.clientGone(sess, h.ac, true)
1464- case keys.ActionCloseTab:
1465- tb := sess.activeTab()
1466- if tb != nil {
1467- h.d.closeTab(sess, tb, true)
1468- }
1469- case keys.ActionCopyMode:
1470- h.d.enterCopyMode(sess, h.ac)
1471- case keys.ActionRenameSession:
1472- sess.promoteEphemeral()
1473- h.d.paint(sess, h.ac, true)
1474- case keys.ActionOpenPicker:
1475- h.d.enterPicker(sess, h.ac)
1476- case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
1477- keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
1478- keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
1479- idx := int(action - keys.ActionSwitchTab1)
1480- if sess.switchTab(idx) {
1481- h.d.paint(sess, h.ac, true)
1482- }
1483- }
1484-}
1485-
1486-func (s *session) switchTab(idx int) bool {
1487- s.mu.Lock()
1488- defer s.mu.Unlock()
1489- if idx < 0 || idx >= len(s.tabs) || idx == s.active {
1490- return false
1491- }
1492- s.active = idx
1493- return true
1494-}
1495-
1496-func (s *session) switchRelative(delta int) bool {
1497- s.mu.Lock()
1498- defer s.mu.Unlock()
1499- if len(s.tabs) < 2 {
1500- return false
1501- }
1502- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
1503- return true
1504-}
1505-
1506-// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
1507-// the current ephemeral numeric name is kept and the session is made persistent.
1508-func (s *session) promoteEphemeral() {
1509- s.mu.Lock()
1510- defer s.mu.Unlock()
1511- s.ephemeral = false
1512-}
1513-
1514-func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
1515- sess.mu.Lock()
1516- idx := -1
1517- for i, w := range sess.tabs {
1518- if w == tb {
1519- idx = i
1520- break
1521- }
1522- }
1523- if idx == -1 {
1524- sess.mu.Unlock()
1525- return
1526- }
1527- if len(sess.tabs) == 1 {
1528- sess.mu.Unlock()
1529- d.killSession(sess, ports.ReasonSessionKilled)
1530- return
1531- }
1532- sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1533- if sess.active >= len(sess.tabs) {
1534- sess.active = len(sess.tabs) - 1
1535- } else if idx < sess.active {
1536- sess.active--
1537- }
1538- ac := sess.client
1539- sess.mu.Unlock()
1540-
1541- d.clearDestroyedTabPreview(tb)
1542- if tb.cancel != nil {
1543- tb.cancel()
1544- }
1545- _ = tb.pty.Close()
1546- if repaint && ac != nil {
1547- d.paint(sess, ac, true)
1548- }
1549-}
1550-
1551-// ptyReader drains child output into the VT screen and pokes the dirty channel
1552-// (non-blocking: a full channel already means a render is pending). On any read
1553-// error (EOF when the child exits) it kills the session.
1554-func signal(ch chan struct{}) {
1555- select {
1556- case ch <- struct{}{}:
1557- default:
1558- }
1559-}
1560-
1561-func (d *Daemon) ptyReader(sess *session, tb *tab) {
1562- defer d.sessWg.Done()
1563- buf := make([]byte, ptyReadBufSize)
1564- var resp []byte
1565- tb.mu.Lock()
1566- tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1567- tb.mu.Unlock()
1568- for {
1569- n, err := tb.pty.Read(buf)
1570- if n > 0 {
1571- data := buf[:n]
1572- tb.mu.Lock()
1573- wasSyncing := tb.screen.SyncUpdateActive()
1574- tb.screen.Write(data)
1575- isSyncing := tb.screen.SyncUpdateActive()
1576- tb.mu.Unlock()
1577- if len(resp) > 0 {
1578- if _, writeErr := tb.pty.Write(resp); writeErr != nil {
1579- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
1580- }
1581- resp = resp[:0]
1582- }
1583- if wasSyncing != isSyncing {
1584- tb.mu.Lock()
1585- tb.syncGen++
1586- gen := tb.syncGen
1587- tb.mu.Unlock()
1588- if isSyncing {
1589- go d.syncWatchdog(tb, gen)
1590- }
1591- }
1592- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
1593- signal(tb.flush)
1594- continue
1595- }
1596- if isSyncing {
1597- continue
1598- }
1599- signal(tb.dirty)
1600- }
1601- if err != nil {
1602- d.closeTab(sess, tb, true)
1603- return
1604- }
1605- }
1606-}
1607-
1608-// scheduler debounces dirty signals. The first dirty opens a short tab;
1609-// sustained floods progressively widen that tab, while isolated updates
1610-// return to the minimum delay for interactive latency.
1611-func (d *Daemon) scheduler(sess *session, tb *tab) {
1612- defer d.sessWg.Done()
1613- delay := minDebounceInterval
1614- lastRender := d.clock.Now()
1615-outer:
1616- for {
1617- select {
1618- case <-sess.ctx.Done():
1619- return
1620- case <-tabDone(tb):
1621- return
1622- case <-tb.flush:
1623- d.render(sess, tb)
1624- lastRender = d.clock.Now()
1625- continue
1626- case <-tb.dirty:
1627- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
1628- delay = minDebounceInterval
1629- }
1630- }
1631-
1632- coalesced := 0
1633- timer := d.clock.NewTimer(delay)
1634- absorb:
1635- for {
1636- select {
1637- case <-sess.ctx.Done():
1638- timer.Stop()
1639- return
1640- case <-tabDone(tb):
1641- timer.Stop()
1642- return
1643- case <-tb.flush:
1644- if !timer.Stop() {
1645- select {
1646- case <-timer.C():
1647- default:
1648- }
1649- }
1650- d.render(sess, tb)
1651- lastRender = d.clock.Now()
1652- continue outer
1653- case <-tb.dirty:
1654- coalesced++
1655- case <-timer.C():
1656- break absorb
1657- }
1658- }
1659- delay = nextDebounceDelay(delay, coalesced)
1660- d.render(sess, tb)
1661- lastRender = d.clock.Now()
1662- }
1663-}
1664-
1665-func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
1666- if coalesced == 0 {
1667- return minDebounceInterval
1668- }
1669- if delay >= maxDebounceInterval {
1670- return maxDebounceInterval
1671- }
1672- delay += debounceStep
1673- if delay > maxDebounceInterval {
1674- return maxDebounceInterval
1675- }
1676- return delay
1677-}
1678-
1679-func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
1680- timer := d.clock.NewTimer(maxSyncUpdateDuration)
1681- select {
1682- case <-tabDone(tb):
1683- timer.Stop()
1684- return
1685- case <-timer.C():
1686- }
1687-
1688- tb.mu.Lock()
1689- if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
1690- tb.mu.Unlock()
1691- return
1692- }
1693- tb.screen.ForceSyncEnd()
1694- tb.mu.Unlock()
1695- signal(tb.flush)
1696-}
1697-
1698-func tabDone(tb *tab) <-chan struct{} {
1699- if tb.ctx == nil {
1700- return nil
1701- }
1702- return tb.ctx.Done()
1703-}
1704-
1705-func syncUpdateEndIn(data []byte) bool {
1706- return bytes.Contains(data, []byte("\x1b[?2026l"))
1707-}
1708-
1709-// render paints the current client, or (when detached) just clears accumulated
1710-// damage so it never grows unbounded while headless.
1711-func (d *Daemon) render(sess *session, tb *tab) {
1712- tb.mu.Lock()
1713- if tb.screen.SyncUpdateActive() {
1714- tb.mu.Unlock()
1715- return
1716- }
1717- tb.mu.Unlock()
1718-
1719- tb.mu.Lock()
1720- previewer := tb.previewClient
1721- tb.mu.Unlock()
1722-
1723- sess.mu.Lock()
1724- ac := sess.client
1725- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
1726- sess.mu.Unlock()
1727-
1728- if previewer != nil {
1729- if previewSess := previewer.currentSession(); previewSess != nil {
1730- d.paint(previewSess, previewer, false)
1731- }
1732- if ac != nil && active && ac != previewer {
1733- d.paint(sess, ac, false)
1734- return
1735- }
1736- tb.mu.Lock()
1737- tb.screen.ClearDamage()
1738- tb.mu.Unlock()
1739- return
1740- }
1741-
1742- if ac == nil || !active {
1743- tb.mu.Lock()
1744- tb.screen.ClearDamage()
1745- tb.mu.Unlock()
1746- return
1747- }
1748- d.paint(sess, ac, false)
1749-}
1750-
1751-// paint draws the composed client frame (active tab plus status bar) and
1752-// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
1753-// such as switch/create/close/rename/resize so the repaint is complete.
1754-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1755- tb := sess.activeTab()
1756- if tb == nil {
1757- return
1758- }
1759-
1760- ac.sendMu.Lock()
1761- ac.copyMu.Lock()
1762- copyActive := ac.copyMode != nil
1763- var copyMode *scopy.Mode
1764- if ac.copyMode != nil {
1765- copyModeValue := *ac.copyMode
1766- copyMode = &copyModeValue
1767- }
1768- copyFeedback := ac.copyFeedback
1769- if copyFeedback != "" && !copyActive {
1770- ac.copyFeedback = ""
1771- }
1772- ac.copyMu.Unlock()
1773- ac.pickerMu.Lock()
1774- pickerActive := ac.picker != nil
1775- pickerModel := ac.picker
1776- previewTab := ac.pickerPreview
1777- preview := snapshotPickerPreview(previewTab)
1778-
1779- tb.mu.Lock()
1780- if reset || copyActive || pickerActive {
1781- ac.rend.Reset()
1782- }
1783- if reset || pickerActive {
1784- ac.lastCursor.valid = false
1785- }
1786- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
1787- if copyActive {
1788- frame, damage = composeCopyClientFrame(copyMode, tb)
1789- }
1790- if pickerActive {
1791- if previewTab == tb {
1792- preview = pickerPreviewFromLockedTab(tb)
1793- }
1794- frame, damage = composePickerClientFrame(pickerModel, preview, frame)
1795- }
1796- ac.pickerMu.Unlock()
1797- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
1798- data, err := ac.rend.Draw(frame, damage)
1799- var cursorTail []byte
1800- if err == nil {
1801- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1802- }
1803- tb.screen.ClearDamage()
1804- tb.mu.Unlock()
1805-
1806- var serr error
1807- if err == nil {
1808- data = append(data, cursorTail...)
1809- if len(data) > 0 {
1810- serr = ac.tr.Send(frameOutput(data))
1811- }
1812- }
1813- ac.sendMu.Unlock()
1814-
1815- if err != nil {
1816- d.log.Error("render draw failed", "err", err, "session", sess.name)
1817- return
1818- }
1819- if serr != nil {
1820- d.detachOnSendError(sess, ac)
1821- }
1822-}
1823-
1824-// resize applies a client size change to every tab using rows-1 for the PTY
1825-// and VT screen, then resets the renderer shadow and repaints the composed
1826-// client-sized frame (including the status row).
1827-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
1828- if copyActive || !s.CursorVisible() {
1829- return cursorOut{hidden: true}
1830- }
1831- style, ok := s.CursorStyle()
1832- if !ok {
1833- style = 5
1834- }
1835- return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
1836-}
1837-
1838-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
1839- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
1840- if !changed {
1841- return nil
1842- }
1843- prev := ac.lastCursor
1844- ac.lastCursor = desired
1845- ac.lastCursor.valid = true
1846- if desired.hidden {
1847- return []byte("\x1b[?25l")
1848- }
1849- var b []byte
1850- b = append(b, "\x1b["...)
1851- b = strconv.AppendInt(b, int64(desired.row+1), 10)
1852- b = append(b, ';')
1853- b = strconv.AppendInt(b, int64(desired.col+1), 10)
1854- b = append(b, 'H')
1855- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
1856- b = append(b, "\x1b["...)
1857- b = strconv.AppendInt(b, int64(desired.style), 10)
1858- b = append(b, " q"...)
1859- }
1860- b = append(b, "\x1b[?25h"...)
1861- return b
1862-}
1863-
1864-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
1865- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1866- frame := renderer.NewFrame(width, screenRows+1)
1867- for y := range screenRows {
1868- copy(frame.Row(y), tb.screen.Frame.Row(y))
1869- }
1870- drawStatus(frame.Row(screenRows), sess, rightStatus)
1871- if full {
1872- return frame, []renderer.Damage{renderer.FullRedraw()}
1873- }
1874- damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
1875- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
1876- return frame, damage
1877-}
1878-
1879-func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
1880- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
1881- frame := mode.Render(snap)
1882- return frame, []renderer.Damage{renderer.FullRedraw()}
1883-}
1884-
1885-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
1886- inner := pickerModal.Composite(base, renderer.DefaultStyle())
1887- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
1888- for y := range min(inner.Height, modalFrame.Height) {
1889- for x := range min(inner.Width, modalFrame.Width) {
1890- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
1891- }
1892- }
1893- return base, []renderer.Damage{renderer.FullRedraw()}
1894-}
1895-
1896-func snapshotPickerPreview(tb *tab) picker.Preview {
1897- if tb == nil {
1898- return picker.Preview{}
1899- }
1900- tb.mu.Lock()
1901- defer tb.mu.Unlock()
1902- return pickerPreviewFromLockedTab(tb)
1903-}
1904-
1905-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1906- rows := make([][]renderer.Cell, tb.screen.Frame.Height)
1907- for y := range rows {
1908- rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
1909- }
1910- return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1911-}
1912-
1913-func drawStatus(row []renderer.Cell, sess *session, rightText string) {
1914- for i := range row {
1915- row[i] = renderer.BlankCell()
1916- }
1917- status := sess.statusSegments()
1918- x := 0
1919- writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
1920- for _, w := range status.tabs {
1921- style := renderer.DefaultStyle()
1922- if w.active {
1923- style.Inverse = true
1924- }
1925- writeStatusText(row, &x, " "+w.name+" ", style)
1926- }
1927- if rightText == "" {
1928- return
1929- }
1930- style := renderer.DefaultStyle()
1931- rightWidth := len([]rune(rightText)) + 1
1932- if len(row)-x-1 < rightWidth {
1933- return
1934- }
1935- x = len(row) - rightWidth
1936- writeStatusText(row, &x, " "+rightText, style)
1937-}
1938-
1939-type statusSnapshot struct {
1940- session string
1941- tabs []statusTab
1942-}
1943-
1944-type statusTab struct {
1945- name string
1946- active bool
1947-}
1948-
1949-func (s *session) statusSegments() statusSnapshot {
1950- s.mu.Lock()
1951- defer s.mu.Unlock()
1952- snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
1953- for i := range s.tabs {
1954- name := strconv.Itoa(i + 1)
1955- snap.tabs[i] = statusTab{name: name, active: i == s.active}
1956- }
1957- return snap
1958-}
1959-
1960-func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
1961- for _, r := range text {
1962- if *x >= len(row) {
1963- return
1964- }
1965- row[*x] = renderer.Cell{Rune: r, Style: style}
1966- (*x)++
1967- }
1968-}
1969-
1970-func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1971- if !sz.Valid() {
1972- return
1973- }
1974- tbSize := tabSize(sz)
1975- ac.size = sz
1976-
1977- sess.mu.Lock()
1978- tabs := append([]*tab(nil), sess.tabs...)
1979- sess.mu.Unlock()
1980- if len(tabs) == 0 {
1981- return
1982- }
1983-
1984- for _, tb := range tabs {
1985- if err := tb.pty.Resize(tbSize); err != nil {
1986- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
1987- }
1988- tb.mu.Lock()
1989- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
1990- tb.size = tbSize
1991- tb.mu.Unlock()
1992- }
1993- d.paint(sess, ac, true)
1994-}
1995-
1996-// detachOnSendError drops a client whose transport failed. Like every other
1997-// detach path, losing the client kills an ephemeral session (its lifetime is
1998-// its client's); a named session keeps running headless.
1999-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
2000- if sess.detachIfCurrent(ac) {
2001- d.unregisterPreview(ac)
2002- _ = ac.tr.Close()
2003- d.log.Warn("detached client after send error", "session", sess.name)
2004- if sess.ephemeral {
2005- d.killSession(sess, ports.ReasonSessionKilled)
2006- }
2007- }
2008-}
2009-
2010-// killSession removes a session and tears down its resources. It is
2011-// idempotent: only the caller that wins the registry delete acts. When the
2012-// registry empties it marks the daemon closing (atomically with the
2013-// empty-check, under d.mu) and signals shutdown.
2014-//
2015-// Teardown ordering matters: context cancel, pty.Close, and the done signal
2016-// run first and unconditionally — never gated behind a client send. The
2017-// Detached notice is best-effort, asynchronous, and deadline-bounded.
2018-func (d *Daemon) killSession(sess *session, reason uint8) {
2019- d.mu.Lock()
2020- if _, ok := d.sessions[sess.id]; !ok {
2021- d.mu.Unlock()
2022- return
2023- }
2024- delete(d.sessions, sess.id)
2025- empty := len(d.sessions) == 0
2026- if empty {
2027- // Shutdown is now inevitable and irreversible (doneOnce below): stop
2028- // route from inserting new sessions from this instant, while we still
2029- // hold the same lock route checks under.
2030- d.closing = true
2031- }
2032- d.mu.Unlock()
2033-
2034- sess.mu.Lock()
2035- ac := sess.client
2036- sess.client = nil
2037- sess.mu.Unlock()
2038- if ac != nil {
2039- d.unregisterPreview(ac)
2040- ac.setSession(nil)
2041- }
2042-
2043- sess.cancel()
2044- sess.mu.Lock()
2045- tabs := append([]*tab(nil), sess.tabs...)
2046- sess.mu.Unlock()
2047- for _, tb := range tabs {
2048- d.clearDestroyedTabPreview(tb)
2049- _ = tb.pty.Close()
2050- }
2051- if empty {
2052- d.doneOnce.Do(func() { close(d.done) })
2053- }
2054-
2055- if ac != nil {
2056- d.notifyDetachedAsync(ac, reason)
2057- }
2058-}
2059-
2060-// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
2061-// d.mu.
2062-func (d *Daemon) allocEphemeralNameLocked() string {
2063- used := make(map[string]struct{}, len(d.sessions))
2064- for _, s := range d.sessions {
2065- used[s.name] = struct{}{}
2066- }
2067- for i := 0; ; i++ {
2068- n := strconv.Itoa(i)
2069- if _, taken := used[n]; !taken {
2070- return n
2071- }
2072- }
2073-}
2074-
2075-func (d *Daemon) findByNameLocked(name string) *session {
2076- for _, s := range d.sessions {
2077- if s.name == name {
2078- return s
2079- }
2080- }
2081- return nil
2082-}
2083-
2084-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
2085-
2086-// childEnv builds the session child's environment: the daemon's own, with TERM
2087-// and VEV forced to well-known values.
2088-func (d *Daemon) childEnv(name string) []string {
2089- out := make([]string, 0, len(d.baseEnv)+2)
2090- for _, e := range d.baseEnv {
2091- if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
2092- continue
2093- }
2094- out = append(out, e)
2095- }
2096- return append(out, "TERM=xterm-256color", "VEV="+name)
2097-}
2098-
2099-func frameWelcome(s *session) ports.Frame {
2100- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
2101- SessionID: string(s.id),
2102- SessionName: s.name,
2103- Ephemeral: s.ephemeral,
2104- })}
2105-}
2106-
2107-func frameError(code uint16, text string) ports.Frame {
2108- return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2109-}
2110-
2111-func frameOutput(b []byte) ports.Frame {
2112- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
2113-}
2114-
2115-func frameDetached(reason uint8) ports.Frame {
2116- return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
2117-}
2118-
2119-func framePong() ports.Frame {
2120- return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
2121-}
2122-
2123-func frameSessions(infos []ports.SessionInfo) ports.Frame {
2124- return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
2125-}
@@ -26,27 +26,16 @@
26 package daemon26 package daemon
27 27
28 import (28 import (
29- "bytes"
30 "context"29 "context"
31 "errors"30 "errors"
32- "fmt"
33 "log/slog"31 "log/slog"
34 "os"32 "os"
35 "sort"33 "sort"
36- "strconv"
37- "strings"
38 "sync"34 "sync"
39 "time"35 "time"
40 36
41 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
42 "github.com/bnema/vev/internal/ports"38 "github.com/bnema/vev/internal/ports"
43- scopy "github.com/bnema/vev/internal/usecase/copy"
44- "github.com/bnema/vev/internal/usecase/keys"
45- "github.com/bnema/vev/internal/usecase/mouse"
46- "github.com/bnema/vev/internal/usecase/picker"
47- "github.com/bnema/vev/internal/usecase/ui"
48- "github.com/bnema/vev/pkg/renderer"
49- "github.com/bnema/vev/pkg/vt"
50 )39 )
51 40
52 // Scheduler debounce bounds. Idle updates use the minimum for low latency;41 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -58,9 +47,6 @@ const (
58 maxSyncUpdateDuration = 500 * time.Millisecond47 maxSyncUpdateDuration = 500 * time.Millisecond
59 )48 )
60 49
61-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
62-
63-// debounceInterval is kept as a test/back-compat alias for the initial delay.
64 const debounceInterval = minDebounceInterval50 const debounceInterval = minDebounceInterval
65 51
66 // ptyReadBufSize is the PTY reader's read buffer.52 // ptyReadBufSize is the PTY reader's read buffer.
@@ -78,8 +64,6 @@ const detachNotifyTimeout = time.Second
78 // defaultSize is used when a client's Hello carries no valid dimensions.64 // defaultSize is used when a client's Hello carries no valid dimensions.
79 var defaultSize = domain.Size{Cols: 80, Rows: 24}65 var defaultSize = domain.Size{Cols: 80, Rows: 24}
80 66
81-// Daemon is vev's server-side multiplexer. Construct it with New and drive it
82-// with Serve.
83 type Daemon struct {67 type Daemon struct {
84 mu sync.Mutex68 mu sync.Mutex
85 sessions map[domain.SessionID]*session69 sessions map[domain.SessionID]*session
@@ -121,209 +105,7 @@ type Daemon struct {
121 }105 }
122 106
123 // session is a single multiplexed session. It owns one or more full-screen107 // session is a single multiplexed session. It owns one or more full-screen
124-// tabs and at most one attached client.
125-type session struct {
126- id domain.SessionID
127- name string
128- ephemeral bool
129-
130- ctx context.Context
131- cancel context.CancelFunc
132-
133- mu sync.Mutex // guards tabs, active, and client
134- tabs []*tab
135- active int
136- client *attachedClient
137- cwd string
138-}
139-
140-// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
141-// applies back-pressure by collapsing (a full buffer means "a render is
142-// already pending"), never by blocking the reader.
143-type tab struct {
144- pty ports.PTY
145- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
146- screen *vt.Screen
147- scrollback *scopy.Scrollback
148- dirty chan struct{}
149- flush chan struct{}
150- syncGen uint64
151- // previewClient tracks the one client currently previewing this tab in the picker.
152- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
153- previewClient *attachedClient
154- size domain.Size
155- ctx context.Context
156- cancel context.CancelFunc
157-}
158-
159-// attachedClient is a client currently attached to a session's tab. rend is
160-// its private renderer shadow (so each client gets output minimised against
161-// what it has actually seen). sendMu serialises the two senders — the render
162-// scheduler and the connection handler — so the transport's single-writer
163-// contract holds and Draw→Send stays atomic (correct output ordering).
164-type attachedClient struct {
165- tr ports.Transport
166- rend *renderer.Renderer
167- size domain.Size
168- keys *keys.Router
169- sessMu sync.Mutex
170- sess *session
171- copyMu sync.Mutex
172- copyMode *scopy.Mode
173- copyPending []byte
174- copyESC pendingByteTimer
175- copyFeedback string
176- pickerMu sync.Mutex
177- picker *picker.Model
178- pickerPreview *tab
179- pickerPending []byte
180- pickerESC pendingByteTimer
181- mouseScan mouse.Scanner
182- lastCursor cursorOut
183- sendMu sync.Mutex
184-}
185-
186-type cursorOut struct {
187- valid bool
188- hidden bool
189- row int
190- col int
191- style int
192- hasStyle bool
193-}
194-
195-type pendingByteTimer struct {
196- timer ports.Timer
197- done chan struct{}
198-}
199-
200-func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
201- p.timer = clock.NewTimer(delay)
202- p.done = make(chan struct{})
203- go func(timer ports.Timer, done <-chan struct{}) {
204- select {
205- case <-timer.C():
206- case <-done:
207- return
208- }
209- onFire(timer)
210- }(p.timer, p.done)
211-}
212-
213-func (p *pendingByteTimer) stop() {
214- if p.timer != nil {
215- p.timer.Stop()
216- p.timer = nil
217- }
218- if p.done != nil {
219- close(p.done)
220- p.done = nil
221- }
222-}
223-
224-func (ac *attachedClient) currentSession() *session {
225- ac.sessMu.Lock()
226- defer ac.sessMu.Unlock()
227- return ac.sess
228-}
229-
230-func (ac *attachedClient) setSession(sess *session) {
231- ac.sessMu.Lock()
232- defer ac.sessMu.Unlock()
233- ac.sess = sess
234-}
235-
236-func (ac *attachedClient) copyModeActive() bool {
237- ac.copyMu.Lock()
238- defer ac.copyMu.Unlock()
239- return ac.copyMode != nil
240-}
241-
242-func (ac *attachedClient) pickerActive() bool {
243- ac.pickerMu.Lock()
244- defer ac.pickerMu.Unlock()
245- return ac.picker != nil
246-}
247-
248-// send serialises a frame onto the client's transport.
249-func (ac *attachedClient) send(f ports.Frame) error {
250- ac.sendMu.Lock()
251- defer ac.sendMu.Unlock()
252- return ac.tr.Send(f)
253-}
254-
255-// boundedSend sends f to ac with a deadline watchdog: if the send (including
256-// waiting on sendMu behind a wedged paint) does not complete within
257-// detachNotifyTimeout, the transport is force-closed, failing the in-flight
258-// write. Detach/kill/shutdown paths use this so they are never gated on a
259-// client that has stopped draining its socket.
260-func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
261- _ = d.boundedSendErr(ac, f)
262-}
263 108
264-func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
265- timer := d.clock.NewTimer(detachNotifyTimeout)
266- result := make(chan error, 1)
267- go func() {
268- result <- ac.send(f)
269- }()
270- select {
271- case err := <-result:
272- timer.Stop()
273- return err
274- case <-timer.C():
275- select {
276- case err := <-result:
277- return err
278- default:
279- }
280- _ = ac.tr.Close()
281- return errors.New("send timed out")
282- }
283-}
284-
285-// notifyDetachedAsync delivers a best-effort Detached notice off the caller's
286-// goroutine and then closes the transport. Session teardown (killSession) must
287-// complete regardless of client state, so the notice is both asynchronous and
288-// deadline-bounded; Serve waits for pending notices before force-closing
289-// connections so a graceful notice is not raced by the hard close.
290-func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
291- done := make(chan struct{})
292- d.mu.Lock()
293- // Prune completed entries so the slice stays bounded by the number of
294- // notifications actually in flight.
295- kept := d.notifies[:0]
296- for _, c := range d.notifies {
297- select {
298- case <-c:
299- default:
300- kept = append(kept, c)
301- }
302- }
303- d.notifies = append(kept, done)
304- d.mu.Unlock()
305-
306- go func() {
307- defer close(done)
308- d.boundedSend(ac, frameDetached(reason))
309- _ = ac.tr.Close()
310- }()
311-}
312-
313-// waitNotifies blocks until every Detached notification in flight at the time
314-// of the call has completed. Each is deadline-bounded (boundedSend), so this
315-// wait is bounded too.
316-func (d *Daemon) waitNotifies() {
317- d.mu.Lock()
318- snapshot := make([]chan struct{}, len(d.notifies))
319- copy(snapshot, d.notifies)
320- d.mu.Unlock()
321- for _, c := range snapshot {
322- <-c
323- }
324-}
325-
326-// Option customises a Daemon at construction.
327 type Option func(*Daemon)109 type Option func(*Daemon)
328 110
329 // WithShell overrides the command (and its args) each session spawns. The111 // WithShell overrides the command (and its args) each session spawns. The
@@ -612,1514 +394,3 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
612 return nil, nil, &protoErr{ports.ErrInternal, "unknown intent"}394 return nil, nil, &protoErr{ports.ErrInternal, "unknown intent"}
613 }395 }
614 }396 }
615-
616-// createSessionLocked spawns a PTY-backed session and starts its reader and
617-// scheduler goroutines. Caller holds d.mu.
618-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
619- tbSize := tabSize(sz)
620- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
621- if err != nil {
622- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
623- }
624-
625- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
626- d.nextID++
627-
628- tb := newTab(pty, tbSize)
629- sctx, cancel := context.WithCancel(d.serveCtx)
630- tb.ctx, tb.cancel = context.WithCancel(sctx)
631- sess := &session{
632- id: id,
633- name: name,
634- ephemeral: ephemeral,
635- ctx: sctx,
636- cancel: cancel,
637- tabs: []*tab{tb},
638- cwd: cwd,
639- }
640- d.sessions[id] = sess
641- d.startTabGoroutines(sess, tb)
642- return sess, nil
643-}
644-
645-func (d *Daemon) createTab(sess *session, sz domain.Size) error {
646- tbSize := tabSize(sz)
647- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
648- if err != nil {
649- return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
650- }
651- tb := newTab(pty, tbSize)
652- tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
653- sess.mu.Lock()
654- sess.tabs = append(sess.tabs, tb)
655- sess.active = len(sess.tabs) - 1
656- sess.mu.Unlock()
657- d.startTabGoroutines(sess, tb)
658- return nil
659-}
660-
661-func newTab(pty ports.PTY, sz domain.Size) *tab {
662- sb := scopy.NewScrollback(defaultScrollbackRows)
663- screen := vt.NewScreen(sz.Cols, sz.Rows)
664- screen.OnLineEvicted = sb.Append
665- return &tab{
666- pty: pty,
667- screen: screen,
668- scrollback: sb,
669- dirty: make(chan struct{}, 1),
670- flush: make(chan struct{}, 1),
671- size: sz,
672- }
673-}
674-
675-func tabSize(clientSize domain.Size) domain.Size {
676- if !clientSize.Valid() {
677- clientSize = defaultSize
678- }
679- rows := max(clientSize.Rows-1, 1)
680- return domain.Size{Cols: clientSize.Cols, Rows: rows}
681-}
682-
683-func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
684- d.sessWg.Add(2)
685- go d.ptyReader(sess, tb)
686- go d.scheduler(sess, tb)
687-}
688-
689-// attachClient makes ac the session's current client, displacing any prior one
690-// (which is notified with ReasonDetach and disconnected — its own conn handler
691-// then unwinds without killing the session, since it is no longer current).
692-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) *attachedClient {
693- ac := &attachedClient{
694- tr: tr,
695- rend: renderer.New(renderer.Capabilities{}),
696- size: sz,
697- }
698- ac.setSession(sess)
699- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
700- sess.mu.Lock()
701- old := sess.client
702- sess.client = ac
703- sess.mu.Unlock()
704-
705- if old != nil {
706- d.unregisterPreview(old)
707- old.setSession(nil)
708- // Async + bounded: a dead or wedged old client must not stall the new
709- // client's handshake.
710- d.notifyDetachedAsync(old, ports.ReasonDetach)
711- }
712- return ac
713-}
714-
715-// firstPaint guarantees the freshly attached client sees the full screen: if
716-// the tab size differs from the client's it resizes first (which paints a
717-// full redraw), otherwise it forces a full paint against the fresh renderer.
718-func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
719- tb := sess.activeTab()
720- if tb == nil {
721- return
722- }
723- tb.mu.Lock()
724- wsz := tb.size
725- tb.mu.Unlock()
726-
727- if clientSize.Valid() && wsz != tabSize(clientSize) {
728- d.resize(sess, ac, clientSize)
729- return
730- }
731- d.paint(sess, ac, true)
732-}
733-
734-// runConnLoop is the per-connection input router: it pumps client messages
735-// until detach, EOF, or a transport error.
736-func (d *Daemon) runConnLoop(ac *attachedClient) {
737- for {
738- sess := ac.currentSession()
739- if sess == nil {
740- return
741- }
742- f, err := ac.tr.Recv()
743- if err != nil {
744- d.clientGone(sess, ac, false)
745- return
746- }
747- sess = ac.currentSession()
748- if sess == nil {
749- return
750- }
751- switch f.Type {
752- case ports.MsgInput:
753- if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
754- d.handleInput(sess, ac, in.Data)
755- }
756- case ports.MsgResize:
757- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
758- d.resize(sess, ac, rz.Size)
759- }
760- case ports.MsgDetach:
761- d.clientGone(sess, ac, true)
762- return
763- case ports.MsgPing:
764- _ = ac.send(framePong())
765- default:
766- // Unknown/out-of-band client messages are ignored so a newer
767- // client can add message types without breaking an older daemon.
768- }
769- }
770-}
771-
772-// clientGone detaches ac if it is still the session's current client. An
773-// ephemeral session dies with its client; a named one survives headless. When
774-// explicit (a MsgDetach), the client is acked with ReasonDetach.
775-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
776- if !sess.detachIfCurrent(ac) {
777- return // already displaced by a newer client; nothing to do
778- }
779- d.unregisterPreview(ac)
780- if explicit {
781- // Synchronous so the ack is delivered before the transport closes
782- // (the client is actively awaiting it), but deadline-bounded so a
783- // wedged client cannot pin this conn handler and hang Serve's
784- // connWg.Wait.
785- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
786- }
787- _ = ac.tr.Close()
788- if sess.ephemeral {
789- d.killSession(sess, ports.ReasonSessionKilled)
790- }
791-}
792-
793-// detachIfCurrent clears the client iff ac is the current one, reporting
794-// whether it acted.
795-func (s *session) detachIfCurrent(ac *attachedClient) bool {
796- s.mu.Lock()
797- defer s.mu.Unlock()
798- if s.client == ac {
799- s.client = nil
800- ac.setSession(nil)
801- return true
802- }
803- return false
804-}
805-
806-func (s *session) activeTab() *tab {
807- s.mu.Lock()
808- defer s.mu.Unlock()
809- if s.active < 0 || s.active >= len(s.tabs) {
810- return nil
811- }
812- return s.tabs[s.active]
813-}
814-
815-func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
816- ac.mouseScan.Scan(data,
817- func(ev mouse.Event) { d.handleMouse(ac, ev) },
818- func(b []byte) {
819- if ac.pickerActive() {
820- d.handlePickerInput(ac, b)
821- return
822- }
823- if ac.copyModeActive() {
824- d.handleCopyInput(ac, b)
825- return
826- }
827- ac.keys.Route(b)
828- },
829- )
830-}
831-
832-func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
833- if ac.pickerActive() {
834- return
835- }
836- sess := ac.currentSession()
837- if sess == nil {
838- return
839- }
840- tb := sess.activeTab()
841- if tb == nil {
842- return
843- }
844-
845- if ac.copyModeActive() {
846- switch ev.Button {
847- case mouse.WheelUp:
848- d.copyWheel(sess, ac, -3)
849- case mouse.WheelDown:
850- d.copyWheel(sess, ac, 3)
851- }
852- return
853- }
854-
855- tb.mu.Lock()
856- childRows := tb.screen.Frame.Height
857- mouseMode, mouseSGR := tb.screen.MouseMode()
858- altScreen := tb.screen.AltScreenActive()
859- tb.mu.Unlock()
860-
861- if mouseMode != 0 {
862- if !mouseSGR || ev.Row >= childRows {
863- return
864- }
865- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
866- return
867- }
868-
869- switch ev.Button {
870- case mouse.WheelUp:
871- if altScreen {
872- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
873- return
874- }
875- d.enterCopyMode(sess, ac)
876- d.copyWheel(sess, ac, -3)
877- case mouse.WheelDown:
878- if altScreen {
879- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
880- }
881- }
882-}
883-
884-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
885- tb := sess.activeTab()
886- if tb == nil {
887- return
888- }
889-
890- tb.mu.Lock()
891- ac.copyMu.Lock()
892- if ac.copyMode == nil {
893- ac.copyMu.Unlock()
894- tb.mu.Unlock()
895- return
896- }
897- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
898- if delta > 0 && ac.copyMode.AtBottom(snap) {
899- ac.copyMode = nil
900- ac.copyMu.Unlock()
901- tb.mu.Unlock()
902- d.paint(sess, ac, true)
903- return
904- }
905- ac.copyMode.Move(snap, delta)
906- exit := delta > 0 && ac.copyMode.AtBottom(snap)
907- if exit {
908- ac.copyMode = nil
909- }
910- ac.copyMu.Unlock()
911- tb.mu.Unlock()
912-
913- d.paint(sess, ac, true)
914-}
915-
916-func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
917- tb := sess.activeTab()
918- if tb == nil {
919- return
920- }
921- tb.mu.Lock()
922- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
923- tb.mu.Unlock()
924- ac.copyMu.Lock()
925- ac.copyMode = scopy.NewMode(snap)
926- ac.copyMu.Unlock()
927- d.paint(sess, ac, true)
928-}
929-
930-func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
931- sess := ac.currentSession()
932- if sess == nil {
933- return
934- }
935- tb := sess.activeTab()
936- if tb == nil {
937- return
938- }
939-
940- tb.mu.Lock()
941- ac.copyMu.Lock()
942- if ac.copyMode == nil {
943- ac.copyPending = nil
944- d.stopCopyPendingTimerLocked(ac)
945- ac.copyMu.Unlock()
946- tb.mu.Unlock()
947- return
948- }
949- if len(ac.copyPending) > 0 {
950- d.stopCopyPendingTimerLocked(ac)
951- combined := make([]byte, 0, len(ac.copyPending)+len(data))
952- combined = append(combined, ac.copyPending...)
953- combined = append(combined, data...)
954- data = combined
955- ac.copyPending = nil
956- }
957- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
958- changed := false
959- copyOut := false
960- exit := false
961- for i := 0; i < len(data); i++ {
962- switch data[i] {
963- case 'j':
964- ac.copyMode.Move(snap, 1)
965- changed = true
966- case 'k':
967- ac.copyMode.Move(snap, -1)
968- changed = true
969- case 'g':
970- ac.copyMode.Top(snap)
971- changed = true
972- case 'G':
973- ac.copyMode.Bottom(snap)
974- changed = true
975- case ' ':
976- ac.copyMode.ToggleSelection()
977- changed = true
978- case '\r', '\n', 'y':
979- copyOut = true
980- exit = true
981- case 'q', 0x03, 0x1b:
982- if data[i] == 0x1b {
983- tail := data[i:]
984- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
985- if ok {
986- i += consumed - 1
987- changed = true
988- continue
989- }
990- if len(tail) == 1 {
991- d.retainCopyESCLocked(ac)
992- break
993- }
994- if isCopyEscapePrefix(tail) {
995- ac.copyPending = append(ac.copyPending[:0], tail...)
996- break
997- }
998- }
999- exit = true
1000- }
1001- }
1002- text := ""
1003- if copyOut {
1004- text = ac.copyMode.SelectedText(snap)
1005- }
1006- if exit {
1007- d.stopCopyPendingTimerLocked(ac)
1008- ac.copyMode = nil
1009- }
1010- ac.copyMu.Unlock()
1011- tb.mu.Unlock()
1012-
1013- if copyOut && text != "" {
1014- chunks := scopy.OSC52(text)
1015- for _, chunk := range chunks {
1016- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
1017- d.detachOnSendError(sess, ac)
1018- return
1019- }
1020- }
1021- ac.copyMu.Lock()
1022- if len(chunks) > 0 {
1023- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
1024- } else {
1025- ac.copyFeedback = "selection too large to copy"
1026- }
1027- ac.copyMu.Unlock()
1028- }
1029- if exit {
1030- d.paint(sess, ac, true)
1031- return
1032- }
1033- if changed {
1034- d.paint(sess, ac, true)
1035- }
1036-}
1037-
1038-func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
1039- mode := ac.copyMode
1040- ac.copyPending = append(ac.copyPending[:0], keys.ESC)
1041- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1042- ac.copyMu.Lock()
1043- if ac.copyESC.timer != timer {
1044- ac.copyMu.Unlock()
1045- return
1046- }
1047- ac.copyPending = nil
1048- ac.copyESC.timer = nil
1049- ac.copyESC.done = nil
1050- if ac.copyMode != mode || ac.copyMode == nil {
1051- ac.copyMu.Unlock()
1052- return
1053- }
1054- ac.copyMode = nil
1055- ac.copyMu.Unlock()
1056-
1057- if sess := ac.currentSession(); sess != nil {
1058- d.paint(sess, ac, true)
1059- }
1060- })
1061-}
1062-
1063-func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
1064- ac.copyESC.stop()
1065-}
1066-
1067-func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
1068- if len(data) >= 3 && data[1] == '[' {
1069- switch data[2] {
1070- case 'A':
1071- m.Move(snap, -1)
1072- return 3, true
1073- case 'B':
1074- m.Move(snap, 1)
1075- return 3, true
1076- }
1077- }
1078- if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
1079- switch data[2] {
1080- case '5':
1081- m.Page(snap, -1)
1082- return 4, true
1083- case '6':
1084- m.Page(snap, 1)
1085- return 4, true
1086- }
1087- }
1088- return 0, false
1089-}
1090-
1091-func isCopyEscapePrefix(data []byte) bool {
1092- return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
1093- len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
1094-}
1095-
1096-func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
1097- views, curTab := d.pickerViews(sess)
1098- ac.pickerMu.Lock()
1099- ac.picker = picker.New(views, sess.id, curTab)
1100- ac.pickerPending = nil
1101- ac.pickerPreview = nil
1102- ac.pickerMu.Unlock()
1103- d.registerPreviewForSelection(ac)
1104- d.paint(sess, ac, true)
1105-}
1106-
1107-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
1108- d.mu.Lock()
1109- sessions := make([]*session, 0, len(d.sessions))
1110- for _, s := range d.sessions {
1111- sessions = append(sessions, s)
1112- }
1113- d.mu.Unlock()
1114- sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
1115-
1116- views := make([]picker.SessionView, 0, len(sessions))
1117- curTab := 0
1118- for _, s := range sessions {
1119- s.mu.Lock()
1120- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
1121- for i := range s.tabs {
1122- view.Tabs[i] = strconv.Itoa(i + 1)
1123- }
1124- if s == cur {
1125- curTab = s.active
1126- }
1127- s.mu.Unlock()
1128- views = append(views, view)
1129- }
1130- return views, curTab
1131-}
1132-
1133-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1134- sess := ac.currentSession()
1135- if sess == nil {
1136- return
1137- }
1138- ac.pickerMu.Lock()
1139- if ac.picker == nil {
1140- ac.pickerPending = nil
1141- d.stopPickerPendingTimerLocked(ac)
1142- ac.pickerMu.Unlock()
1143- return
1144- }
1145- if len(ac.pickerPending) > 0 {
1146- d.stopPickerPendingTimerLocked(ac)
1147- combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1148- combined = append(combined, ac.pickerPending...)
1149- combined = append(combined, data...)
1150- data = combined
1151- ac.pickerPending = nil
1152- }
1153- changed := false
1154- exit := false
1155- switchTarget := false
1156- for i := 0; i < len(data); i++ {
1157- switch data[i] {
1158- case 'j':
1159- ac.picker.Down()
1160- changed = true
1161- case 'k':
1162- ac.picker.Up()
1163- changed = true
1164- case '\r', '\n':
1165- switchTarget = true
1166- exit = true
1167- case 'q', 0x03, 0x1b:
1168- if data[i] == 0x1b {
1169- tail := data[i:]
1170- consumed, ok := routePickerEscape(ac.picker, tail)
1171- if ok {
1172- i += consumed - 1
1173- changed = true
1174- continue
1175- }
1176- if len(tail) == 1 {
1177- d.retainPickerESCLocked(ac)
1178- break
1179- }
1180- if isPickerEscapePrefix(tail) {
1181- ac.pickerPending = append(ac.pickerPending[:0], tail...)
1182- break
1183- }
1184- }
1185- exit = true
1186- }
1187- }
1188- var target picker.Target
1189- var ok bool
1190- if switchTarget {
1191- target, ok = ac.picker.Selected()
1192- }
1193- ac.pickerMu.Unlock()
1194-
1195- if changed {
1196- d.registerPreviewForSelection(ac)
1197- }
1198- if exit {
1199- d.closePicker(ac)
1200- }
1201- if switchTarget && ok {
1202- d.switchToTarget(sess, ac, target)
1203- return
1204- }
1205- if exit || changed {
1206- d.paint(sess, ac, true)
1207- }
1208-}
1209-
1210-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
1211- ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
1212- ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1213- ac.pickerMu.Lock()
1214- if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
1215- ac.pickerMu.Unlock()
1216- return
1217- }
1218- ac.pickerPending = nil
1219- ac.pickerESC.timer = nil
1220- ac.pickerESC.done = nil
1221- ac.picker = nil
1222- ac.pickerMu.Unlock()
1223-
1224- d.unregisterPreview(ac)
1225- if sess := ac.currentSession(); sess != nil {
1226- d.paint(sess, ac, true)
1227- }
1228- })
1229-}
1230-
1231-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
1232- ac.pickerESC.stop()
1233-}
1234-
1235-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1236- if len(data) >= 3 && data[1] == '[' {
1237- switch data[2] {
1238- case 'A':
1239- m.Up()
1240- return 3, true
1241- case 'B':
1242- m.Down()
1243- return 3, true
1244- }
1245- }
1246- return 0, false
1247-}
1248-
1249-func isPickerEscapePrefix(data []byte) bool {
1250- return len(data) == 2 && data[0] == 0x1b && data[1] == '['
1251-}
1252-
1253-func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
1254- ac.pickerMu.Lock()
1255- var target picker.Target
1256- var ok bool
1257- if ac.picker != nil {
1258- target, ok = ac.picker.Selected()
1259- }
1260- ac.pickerMu.Unlock()
1261-
1262- var next *tab
1263- if ok {
1264- next = d.tabByTarget(target)
1265- }
1266-
1267- ac.pickerMu.Lock()
1268- old := ac.pickerPreview
1269- if ac.picker == nil {
1270- next = nil
1271- }
1272- ac.pickerPreview = next
1273- ac.pickerMu.Unlock()
1274-
1275- if old != nil && old != next {
1276- old.mu.Lock()
1277- if old.previewClient == ac {
1278- old.previewClient = nil
1279- }
1280- old.mu.Unlock()
1281- }
1282- if next != nil {
1283- next.mu.Lock()
1284- next.previewClient = ac
1285- next.mu.Unlock()
1286-
1287- ac.pickerMu.Lock()
1288- keep := ac.pickerPreview == next
1289- ac.pickerMu.Unlock()
1290- if !keep {
1291- next.mu.Lock()
1292- if next.previewClient == ac {
1293- next.previewClient = nil
1294- }
1295- next.mu.Unlock()
1296- }
1297- }
1298-}
1299-
1300-func (d *Daemon) unregisterPreview(ac *attachedClient) {
1301- ac.pickerMu.Lock()
1302- old := ac.pickerPreview
1303- ac.pickerPreview = nil
1304- ac.pickerMu.Unlock()
1305-
1306- if old != nil {
1307- old.mu.Lock()
1308- if old.previewClient == ac {
1309- old.previewClient = nil
1310- }
1311- old.mu.Unlock()
1312- }
1313-}
1314-
1315-func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1316- tb.mu.Lock()
1317- previewer := tb.previewClient
1318- tb.mu.Unlock()
1319- if previewer == nil {
1320- return
1321- }
1322- cleared := false
1323- previewer.pickerMu.Lock()
1324- if previewer.pickerPreview == tb {
1325- previewer.pickerPreview = nil
1326- cleared = true
1327- }
1328- previewer.pickerMu.Unlock()
1329- tb.mu.Lock()
1330- if tb.previewClient == previewer {
1331- tb.previewClient = nil
1332- }
1333- tb.mu.Unlock()
1334- if cleared {
1335- if sess := previewer.currentSession(); sess != nil {
1336- d.paint(sess, previewer, true)
1337- }
1338- }
1339-}
1340-
1341-func (d *Daemon) closePicker(ac *attachedClient) {
1342- ac.pickerMu.Lock()
1343- ac.picker = nil
1344- ac.pickerPending = nil
1345- d.stopPickerPendingTimerLocked(ac)
1346- ac.pickerMu.Unlock()
1347- d.unregisterPreview(ac)
1348-}
1349-
1350-func (d *Daemon) tabByTarget(target picker.Target) *tab {
1351- d.mu.Lock()
1352- sess := d.sessions[target.Session]
1353- d.mu.Unlock()
1354- if sess == nil {
1355- return nil
1356- }
1357- sess.mu.Lock()
1358- defer sess.mu.Unlock()
1359- if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
1360- return nil
1361- }
1362- return sess.tabs[target.TabIndex]
1363-}
1364-
1365-func (d *Daemon) sessionByID(id domain.SessionID) *session {
1366- d.mu.Lock()
1367- defer d.mu.Unlock()
1368- return d.sessions[id]
1369-}
1370-
1371-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
1372- targetSess := d.sessionByID(target.Session)
1373- if targetSess == nil {
1374- d.paint(sess, ac, true)
1375- return
1376- }
1377- if targetSess == sess {
1378- sess.switchTab(target.TabIndex)
1379- d.paint(sess, ac, true)
1380- return
1381- }
1382- old := d.stealClientForTarget(sess, ac, targetSess, target)
1383- if ac.currentSession() != targetSess {
1384- d.paint(sess, ac, true)
1385- return
1386- }
1387- if old != nil && old != ac {
1388- d.unregisterPreview(old)
1389- old.setSession(nil)
1390- d.notifyDetachedAsync(old, ports.ReasonDetach)
1391- }
1392- d.firstPaint(targetSess, ac, ac.size)
1393-}
1394-
1395-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1396- d.mu.Lock()
1397- if d.sessions[target.Session] != targetSess {
1398- d.mu.Unlock()
1399- return nil
1400- }
1401- from.mu.Lock()
1402- if from.client != ac {
1403- from.mu.Unlock()
1404- d.mu.Unlock()
1405- return nil
1406- }
1407- from.client = nil
1408- ac.setSession(nil)
1409- from.mu.Unlock()
1410-
1411- targetSess.mu.Lock()
1412- old := targetSess.client
1413- targetSess.client = ac
1414- if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
1415- targetSess.active = target.TabIndex
1416- }
1417- targetSess.mu.Unlock()
1418- ac.setSession(targetSess)
1419- d.mu.Unlock()
1420- return old
1421-}
1422-
1423-type daemonKeyHandler struct {
1424- d *Daemon
1425- ac *attachedClient
1426-}
1427-
1428-func (h daemonKeyHandler) Forward(data []byte) {
1429- sess := h.ac.currentSession()
1430- if sess == nil {
1431- return
1432- }
1433- tb := sess.activeTab()
1434- if tb == nil {
1435- return
1436- }
1437- if _, err := tb.pty.Write(data); err != nil {
1438- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
1439- }
1440-}
1441-
1442-func (h daemonKeyHandler) Action(action keys.Action) {
1443- sess := h.ac.currentSession()
1444- if sess == nil {
1445- return
1446- }
1447- switch action {
1448- case keys.ActionCreateTab:
1449- if err := h.d.createTab(sess, h.ac.size); err != nil {
1450- h.d.log.Error("create tab failed", "err", err, "session", sess.name)
1451- return
1452- }
1453- h.d.paint(sess, h.ac, true)
1454- case keys.ActionNextTab:
1455- if sess.switchRelative(1) {
1456- h.d.paint(sess, h.ac, true)
1457- }
1458- case keys.ActionPreviousTab:
1459- if sess.switchRelative(-1) {
1460- h.d.paint(sess, h.ac, true)
1461- }
1462- case keys.ActionDetach:
1463- h.d.clientGone(sess, h.ac, true)
1464- case keys.ActionCloseTab:
1465- tb := sess.activeTab()
1466- if tb != nil {
1467- h.d.closeTab(sess, tb, true)
1468- }
1469- case keys.ActionCopyMode:
1470- h.d.enterCopyMode(sess, h.ac)
1471- case keys.ActionRenameSession:
1472- sess.promoteEphemeral()
1473- h.d.paint(sess, h.ac, true)
1474- case keys.ActionOpenPicker:
1475- h.d.enterPicker(sess, h.ac)
1476- case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
1477- keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
1478- keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
1479- idx := int(action - keys.ActionSwitchTab1)
1480- if sess.switchTab(idx) {
1481- h.d.paint(sess, h.ac, true)
1482- }
1483- }
1484-}
1485-
1486-func (s *session) switchTab(idx int) bool {
1487- s.mu.Lock()
1488- defer s.mu.Unlock()
1489- if idx < 0 || idx >= len(s.tabs) || idx == s.active {
1490- return false
1491- }
1492- s.active = idx
1493- return true
1494-}
1495-
1496-func (s *session) switchRelative(delta int) bool {
1497- s.mu.Lock()
1498- defer s.mu.Unlock()
1499- if len(s.tabs) < 2 {
1500- return false
1501- }
1502- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
1503- return true
1504-}
1505-
1506-// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
1507-// the current ephemeral numeric name is kept and the session is made persistent.
1508-func (s *session) promoteEphemeral() {
1509- s.mu.Lock()
1510- defer s.mu.Unlock()
1511- s.ephemeral = false
1512-}
1513-
1514-func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
1515- sess.mu.Lock()
1516- idx := -1
1517- for i, w := range sess.tabs {
1518- if w == tb {
1519- idx = i
1520- break
1521- }
1522- }
1523- if idx == -1 {
1524- sess.mu.Unlock()
1525- return
1526- }
1527- if len(sess.tabs) == 1 {
1528- sess.mu.Unlock()
1529- d.killSession(sess, ports.ReasonSessionKilled)
1530- return
1531- }
1532- sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1533- if sess.active >= len(sess.tabs) {
1534- sess.active = len(sess.tabs) - 1
1535- } else if idx < sess.active {
1536- sess.active--
1537- }
1538- ac := sess.client
1539- sess.mu.Unlock()
1540-
1541- d.clearDestroyedTabPreview(tb)
1542- if tb.cancel != nil {
1543- tb.cancel()
1544- }
1545- _ = tb.pty.Close()
1546- if repaint && ac != nil {
1547- d.paint(sess, ac, true)
1548- }
1549-}
1550-
1551-// ptyReader drains child output into the VT screen and pokes the dirty channel
1552-// (non-blocking: a full channel already means a render is pending). On any read
1553-// error (EOF when the child exits) it kills the session.
1554-func signal(ch chan struct{}) {
1555- select {
1556- case ch <- struct{}{}:
1557- default:
1558- }
1559-}
1560-
1561-func (d *Daemon) ptyReader(sess *session, tb *tab) {
1562- defer d.sessWg.Done()
1563- buf := make([]byte, ptyReadBufSize)
1564- var resp []byte
1565- tb.mu.Lock()
1566- tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1567- tb.mu.Unlock()
1568- for {
1569- n, err := tb.pty.Read(buf)
1570- if n > 0 {
1571- data := buf[:n]
1572- tb.mu.Lock()
1573- wasSyncing := tb.screen.SyncUpdateActive()
1574- tb.screen.Write(data)
1575- isSyncing := tb.screen.SyncUpdateActive()
1576- tb.mu.Unlock()
1577- if len(resp) > 0 {
1578- if _, writeErr := tb.pty.Write(resp); writeErr != nil {
1579- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
1580- }
1581- resp = resp[:0]
1582- }
1583- if wasSyncing != isSyncing {
1584- tb.mu.Lock()
1585- tb.syncGen++
1586- gen := tb.syncGen
1587- tb.mu.Unlock()
1588- if isSyncing {
1589- go d.syncWatchdog(tb, gen)
1590- }
1591- }
1592- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
1593- signal(tb.flush)
1594- continue
1595- }
1596- if isSyncing {
1597- continue
1598- }
1599- signal(tb.dirty)
1600- }
1601- if err != nil {
1602- d.closeTab(sess, tb, true)
1603- return
1604- }
1605- }
1606-}
1607-
1608-// scheduler debounces dirty signals. The first dirty opens a short tab;
1609-// sustained floods progressively widen that tab, while isolated updates
1610-// return to the minimum delay for interactive latency.
1611-func (d *Daemon) scheduler(sess *session, tb *tab) {
1612- defer d.sessWg.Done()
1613- delay := minDebounceInterval
1614- lastRender := d.clock.Now()
1615-outer:
1616- for {
1617- select {
1618- case <-sess.ctx.Done():
1619- return
1620- case <-tabDone(tb):
1621- return
1622- case <-tb.flush:
1623- d.render(sess, tb)
1624- lastRender = d.clock.Now()
1625- continue
1626- case <-tb.dirty:
1627- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
1628- delay = minDebounceInterval
1629- }
1630- }
1631-
1632- coalesced := 0
1633- timer := d.clock.NewTimer(delay)
1634- absorb:
1635- for {
1636- select {
1637- case <-sess.ctx.Done():
1638- timer.Stop()
1639- return
1640- case <-tabDone(tb):
1641- timer.Stop()
1642- return
1643- case <-tb.flush:
1644- if !timer.Stop() {
1645- select {
1646- case <-timer.C():
1647- default:
1648- }
1649- }
1650- d.render(sess, tb)
1651- lastRender = d.clock.Now()
1652- continue outer
1653- case <-tb.dirty:
1654- coalesced++
1655- case <-timer.C():
1656- break absorb
1657- }
1658- }
1659- delay = nextDebounceDelay(delay, coalesced)
1660- d.render(sess, tb)
1661- lastRender = d.clock.Now()
1662- }
1663-}
1664-
1665-func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
1666- if coalesced == 0 {
1667- return minDebounceInterval
1668- }
1669- if delay >= maxDebounceInterval {
1670- return maxDebounceInterval
1671- }
1672- delay += debounceStep
1673- if delay > maxDebounceInterval {
1674- return maxDebounceInterval
1675- }
1676- return delay
1677-}
1678-
1679-func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
1680- timer := d.clock.NewTimer(maxSyncUpdateDuration)
1681- select {
1682- case <-tabDone(tb):
1683- timer.Stop()
1684- return
1685- case <-timer.C():
1686- }
1687-
1688- tb.mu.Lock()
1689- if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
1690- tb.mu.Unlock()
1691- return
1692- }
1693- tb.screen.ForceSyncEnd()
1694- tb.mu.Unlock()
1695- signal(tb.flush)
1696-}
1697-
1698-func tabDone(tb *tab) <-chan struct{} {
1699- if tb.ctx == nil {
1700- return nil
1701- }
1702- return tb.ctx.Done()
1703-}
1704-
1705-func syncUpdateEndIn(data []byte) bool {
1706- return bytes.Contains(data, []byte("\x1b[?2026l"))
1707-}
1708-
1709-// render paints the current client, or (when detached) just clears accumulated
1710-// damage so it never grows unbounded while headless.
1711-func (d *Daemon) render(sess *session, tb *tab) {
1712- tb.mu.Lock()
1713- if tb.screen.SyncUpdateActive() {
1714- tb.mu.Unlock()
1715- return
1716- }
1717- tb.mu.Unlock()
1718-
1719- tb.mu.Lock()
1720- previewer := tb.previewClient
1721- tb.mu.Unlock()
1722-
1723- sess.mu.Lock()
1724- ac := sess.client
1725- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
1726- sess.mu.Unlock()
1727-
1728- if previewer != nil {
1729- if previewSess := previewer.currentSession(); previewSess != nil {
1730- d.paint(previewSess, previewer, false)
1731- }
1732- if ac != nil && active && ac != previewer {
1733- d.paint(sess, ac, false)
1734- return
1735- }
1736- tb.mu.Lock()
1737- tb.screen.ClearDamage()
1738- tb.mu.Unlock()
1739- return
1740- }
1741-
1742- if ac == nil || !active {
1743- tb.mu.Lock()
1744- tb.screen.ClearDamage()
1745- tb.mu.Unlock()
1746- return
1747- }
1748- d.paint(sess, ac, false)
1749-}
1750-
1751-// paint draws the composed client frame (active tab plus status bar) and
1752-// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
1753-// such as switch/create/close/rename/resize so the repaint is complete.
1754-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1755- tb := sess.activeTab()
1756- if tb == nil {
1757- return
1758- }
1759-
1760- ac.sendMu.Lock()
1761- ac.copyMu.Lock()
1762- copyActive := ac.copyMode != nil
1763- var copyMode *scopy.Mode
1764- if ac.copyMode != nil {
1765- copyModeValue := *ac.copyMode
1766- copyMode = &copyModeValue
1767- }
1768- copyFeedback := ac.copyFeedback
1769- if copyFeedback != "" && !copyActive {
1770- ac.copyFeedback = ""
1771- }
1772- ac.copyMu.Unlock()
1773- ac.pickerMu.Lock()
1774- pickerActive := ac.picker != nil
1775- pickerModel := ac.picker
1776- previewTab := ac.pickerPreview
1777- preview := snapshotPickerPreview(previewTab)
1778-
1779- tb.mu.Lock()
1780- if reset || copyActive || pickerActive {
1781- ac.rend.Reset()
1782- }
1783- if reset || pickerActive {
1784- ac.lastCursor.valid = false
1785- }
1786- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
1787- if copyActive {
1788- frame, damage = composeCopyClientFrame(copyMode, tb)
1789- }
1790- if pickerActive {
1791- if previewTab == tb {
1792- preview = pickerPreviewFromLockedTab(tb)
1793- }
1794- frame, damage = composePickerClientFrame(pickerModel, preview, frame)
1795- }
1796- ac.pickerMu.Unlock()
1797- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
1798- data, err := ac.rend.Draw(frame, damage)
1799- var cursorTail []byte
1800- if err == nil {
1801- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1802- }
1803- tb.screen.ClearDamage()
1804- tb.mu.Unlock()
1805-
1806- var serr error
1807- if err == nil {
1808- data = append(data, cursorTail...)
1809- if len(data) > 0 {
1810- serr = ac.tr.Send(frameOutput(data))
1811- }
1812- }
1813- ac.sendMu.Unlock()
1814-
1815- if err != nil {
1816- d.log.Error("render draw failed", "err", err, "session", sess.name)
1817- return
1818- }
1819- if serr != nil {
1820- d.detachOnSendError(sess, ac)
1821- }
1822-}
1823-
1824-// resize applies a client size change to every tab using rows-1 for the PTY
1825-// and VT screen, then resets the renderer shadow and repaints the composed
1826-// client-sized frame (including the status row).
1827-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
1828- if copyActive || !s.CursorVisible() {
1829- return cursorOut{hidden: true}
1830- }
1831- style, ok := s.CursorStyle()
1832- if !ok {
1833- style = 5
1834- }
1835- return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
1836-}
1837-
1838-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
1839- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
1840- if !changed {
1841- return nil
1842- }
1843- prev := ac.lastCursor
1844- ac.lastCursor = desired
1845- ac.lastCursor.valid = true
1846- if desired.hidden {
1847- return []byte("\x1b[?25l")
1848- }
1849- var b []byte
1850- b = append(b, "\x1b["...)
1851- b = strconv.AppendInt(b, int64(desired.row+1), 10)
1852- b = append(b, ';')
1853- b = strconv.AppendInt(b, int64(desired.col+1), 10)
1854- b = append(b, 'H')
1855- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
1856- b = append(b, "\x1b["...)
1857- b = strconv.AppendInt(b, int64(desired.style), 10)
1858- b = append(b, " q"...)
1859- }
1860- b = append(b, "\x1b[?25h"...)
1861- return b
1862-}
1863-
1864-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
1865- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1866- frame := renderer.NewFrame(width, screenRows+1)
1867- for y := range screenRows {
1868- copy(frame.Row(y), tb.screen.Frame.Row(y))
1869- }
1870- drawStatus(frame.Row(screenRows), sess, rightStatus)
1871- if full {
1872- return frame, []renderer.Damage{renderer.FullRedraw()}
1873- }
1874- damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
1875- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
1876- return frame, damage
1877-}
1878-
1879-func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
1880- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
1881- frame := mode.Render(snap)
1882- return frame, []renderer.Damage{renderer.FullRedraw()}
1883-}
1884-
1885-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
1886- inner := pickerModal.Composite(base, renderer.DefaultStyle())
1887- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
1888- for y := range min(inner.Height, modalFrame.Height) {
1889- for x := range min(inner.Width, modalFrame.Width) {
1890- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
1891- }
1892- }
1893- return base, []renderer.Damage{renderer.FullRedraw()}
1894-}
1895-
1896-func snapshotPickerPreview(tb *tab) picker.Preview {
1897- if tb == nil {
1898- return picker.Preview{}
1899- }
1900- tb.mu.Lock()
1901- defer tb.mu.Unlock()
1902- return pickerPreviewFromLockedTab(tb)
1903-}
1904-
1905-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1906- rows := make([][]renderer.Cell, tb.screen.Frame.Height)
1907- for y := range rows {
1908- rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
1909- }
1910- return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1911-}
1912-
1913-func drawStatus(row []renderer.Cell, sess *session, rightText string) {
1914- for i := range row {
1915- row[i] = renderer.BlankCell()
1916- }
1917- status := sess.statusSegments()
1918- x := 0
1919- writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
1920- for _, w := range status.tabs {
1921- style := renderer.DefaultStyle()
1922- if w.active {
1923- style.Inverse = true
1924- }
1925- writeStatusText(row, &x, " "+w.name+" ", style)
1926- }
1927- if rightText == "" {
1928- return
1929- }
1930- style := renderer.DefaultStyle()
1931- rightWidth := len([]rune(rightText)) + 1
1932- if len(row)-x-1 < rightWidth {
1933- return
1934- }
1935- x = len(row) - rightWidth
1936- writeStatusText(row, &x, " "+rightText, style)
1937-}
1938-
1939-type statusSnapshot struct {
1940- session string
1941- tabs []statusTab
1942-}
1943-
1944-type statusTab struct {
1945- name string
1946- active bool
1947-}
1948-
1949-func (s *session) statusSegments() statusSnapshot {
1950- s.mu.Lock()
1951- defer s.mu.Unlock()
1952- snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
1953- for i := range s.tabs {
1954- name := strconv.Itoa(i + 1)
1955- snap.tabs[i] = statusTab{name: name, active: i == s.active}
1956- }
1957- return snap
1958-}
1959-
1960-func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
1961- for _, r := range text {
1962- if *x >= len(row) {
1963- return
1964- }
1965- row[*x] = renderer.Cell{Rune: r, Style: style}
1966- (*x)++
1967- }
1968-}
1969-
1970-func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1971- if !sz.Valid() {
1972- return
1973- }
1974- tbSize := tabSize(sz)
1975- ac.size = sz
1976-
1977- sess.mu.Lock()
1978- tabs := append([]*tab(nil), sess.tabs...)
1979- sess.mu.Unlock()
1980- if len(tabs) == 0 {
1981- return
1982- }
1983-
1984- for _, tb := range tabs {
1985- if err := tb.pty.Resize(tbSize); err != nil {
1986- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
1987- }
1988- tb.mu.Lock()
1989- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
1990- tb.size = tbSize
1991- tb.mu.Unlock()
1992- }
1993- d.paint(sess, ac, true)
1994-}
1995-
1996-// detachOnSendError drops a client whose transport failed. Like every other
1997-// detach path, losing the client kills an ephemeral session (its lifetime is
1998-// its client's); a named session keeps running headless.
1999-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
2000- if sess.detachIfCurrent(ac) {
2001- d.unregisterPreview(ac)
2002- _ = ac.tr.Close()
2003- d.log.Warn("detached client after send error", "session", sess.name)
2004- if sess.ephemeral {
2005- d.killSession(sess, ports.ReasonSessionKilled)
2006- }
2007- }
2008-}
2009-
2010-// killSession removes a session and tears down its resources. It is
2011-// idempotent: only the caller that wins the registry delete acts. When the
2012-// registry empties it marks the daemon closing (atomically with the
2013-// empty-check, under d.mu) and signals shutdown.
2014-//
2015-// Teardown ordering matters: context cancel, pty.Close, and the done signal
2016-// run first and unconditionally — never gated behind a client send. The
2017-// Detached notice is best-effort, asynchronous, and deadline-bounded.
2018-func (d *Daemon) killSession(sess *session, reason uint8) {
2019- d.mu.Lock()
2020- if _, ok := d.sessions[sess.id]; !ok {
2021- d.mu.Unlock()
2022- return
2023- }
2024- delete(d.sessions, sess.id)
2025- empty := len(d.sessions) == 0
2026- if empty {
2027- // Shutdown is now inevitable and irreversible (doneOnce below): stop
2028- // route from inserting new sessions from this instant, while we still
2029- // hold the same lock route checks under.
2030- d.closing = true
2031- }
2032- d.mu.Unlock()
2033-
2034- sess.mu.Lock()
2035- ac := sess.client
2036- sess.client = nil
2037- sess.mu.Unlock()
2038- if ac != nil {
2039- d.unregisterPreview(ac)
2040- ac.setSession(nil)
2041- }
2042-
2043- sess.cancel()
2044- sess.mu.Lock()
2045- tabs := append([]*tab(nil), sess.tabs...)
2046- sess.mu.Unlock()
2047- for _, tb := range tabs {
2048- d.clearDestroyedTabPreview(tb)
2049- _ = tb.pty.Close()
2050- }
2051- if empty {
2052- d.doneOnce.Do(func() { close(d.done) })
2053- }
2054-
2055- if ac != nil {
2056- d.notifyDetachedAsync(ac, reason)
2057- }
2058-}
2059-
2060-// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
2061-// d.mu.
2062-func (d *Daemon) allocEphemeralNameLocked() string {
2063- used := make(map[string]struct{}, len(d.sessions))
2064- for _, s := range d.sessions {
2065- used[s.name] = struct{}{}
2066- }
2067- for i := 0; ; i++ {
2068- n := strconv.Itoa(i)
2069- if _, taken := used[n]; !taken {
2070- return n
2071- }
2072- }
2073-}
2074-
2075-func (d *Daemon) findByNameLocked(name string) *session {
2076- for _, s := range d.sessions {
2077- if s.name == name {
2078- return s
2079- }
2080- }
2081- return nil
2082-}
2083-
2084-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
2085-
2086-// childEnv builds the session child's environment: the daemon's own, with TERM
2087-// and VEV forced to well-known values.
2088-func (d *Daemon) childEnv(name string) []string {
2089- out := make([]string, 0, len(d.baseEnv)+2)
2090- for _, e := range d.baseEnv {
2091- if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
2092- continue
2093- }
2094- out = append(out, e)
2095- }
2096- return append(out, "TERM=xterm-256color", "VEV="+name)
2097-}
2098-
2099-func frameWelcome(s *session) ports.Frame {
2100- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
2101- SessionID: string(s.id),
2102- SessionName: s.name,
2103- Ephemeral: s.ephemeral,
2104- })}
2105-}
2106-
2107-func frameError(code uint16, text string) ports.Frame {
2108- return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2109-}
2110-
2111-func frameOutput(b []byte) ports.Frame {
2112- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
2113-}
2114-
2115-func frameDetached(reason uint8) ports.Frame {
2116- return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
2117-}
2118-
2119-func framePong() ports.Frame {
2120- return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
2121-}
2122-
2123-func frameSessions(infos []ports.SessionInfo) ports.Frame {
2124- return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
2125-}
A internal/usecase/daemon/input.go +163 -0
@@ -0,0 +1,163 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/usecase/keys"
30+ "github.com/bnema/vev/internal/usecase/mouse"
31+)
32+
33+func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
34+ ac.mouseScan.Scan(data,
35+ func(ev mouse.Event) { d.handleMouse(ac, ev) },
36+ func(b []byte) {
37+ if ac.pickerActive() {
38+ d.handlePickerInput(ac, b)
39+ return
40+ }
41+ if ac.copyModeActive() {
42+ d.handleCopyInput(ac, b)
43+ return
44+ }
45+ ac.keys.Route(b)
46+ },
47+ )
48+}
49+
50+func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
51+ if ac.pickerActive() {
52+ return
53+ }
54+ sess := ac.currentSession()
55+ if sess == nil {
56+ return
57+ }
58+ tb := sess.activeTab()
59+ if tb == nil {
60+ return
61+ }
62+
63+ if ac.copyModeActive() {
64+ switch ev.Button {
65+ case mouse.WheelUp:
66+ d.copyWheel(sess, ac, -3)
67+ case mouse.WheelDown:
68+ d.copyWheel(sess, ac, 3)
69+ }
70+ return
71+ }
72+
73+ tb.mu.Lock()
74+ childRows := tb.screen.Frame.Height
75+ mouseMode, mouseSGR := tb.screen.MouseMode()
76+ altScreen := tb.screen.AltScreenActive()
77+ tb.mu.Unlock()
78+
79+ if mouseMode != 0 {
80+ if !mouseSGR || ev.Row >= childRows {
81+ return
82+ }
83+ daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
84+ return
85+ }
86+
87+ switch ev.Button {
88+ case mouse.WheelUp:
89+ if altScreen {
90+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
91+ return
92+ }
93+ d.enterCopyMode(sess, ac)
94+ d.copyWheel(sess, ac, -3)
95+ case mouse.WheelDown:
96+ if altScreen {
97+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
98+ }
99+ }
100+}
101+
102+type daemonKeyHandler struct {
103+ d *Daemon
104+ ac *attachedClient
105+}
106+
107+func (h daemonKeyHandler) Forward(data []byte) {
108+ sess := h.ac.currentSession()
109+ if sess == nil {
110+ return
111+ }
112+ tb := sess.activeTab()
113+ if tb == nil {
114+ return
115+ }
116+ if _, err := tb.pty.Write(data); err != nil {
117+ h.d.log.Error("pty write failed", "err", err, "session", sess.name)
118+ }
119+}
120+
121+func (h daemonKeyHandler) Action(action keys.Action) {
122+ sess := h.ac.currentSession()
123+ if sess == nil {
124+ return
125+ }
126+ switch action {
127+ case keys.ActionCreateTab:
128+ if err := h.d.createTab(sess, h.ac.size); err != nil {
129+ h.d.log.Error("create tab failed", "err", err, "session", sess.name)
130+ return
131+ }
132+ h.d.paint(sess, h.ac, true)
133+ case keys.ActionNextTab:
134+ if sess.switchRelative(1) {
135+ h.d.paint(sess, h.ac, true)
136+ }
137+ case keys.ActionPreviousTab:
138+ if sess.switchRelative(-1) {
139+ h.d.paint(sess, h.ac, true)
140+ }
141+ case keys.ActionDetach:
142+ h.d.clientGone(sess, h.ac, true)
143+ case keys.ActionCloseTab:
144+ tb := sess.activeTab()
145+ if tb != nil {
146+ h.d.closeTab(sess, tb, true)
147+ }
148+ case keys.ActionCopyMode:
149+ h.d.enterCopyMode(sess, h.ac)
150+ case keys.ActionRenameSession:
151+ sess.promoteEphemeral()
152+ h.d.paint(sess, h.ac, true)
153+ case keys.ActionOpenPicker:
154+ h.d.enterPicker(sess, h.ac)
155+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
156+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
157+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
158+ idx := int(action - keys.ActionSwitchTab1)
159+ if sess.switchTab(idx) {
160+ h.d.paint(sess, h.ac, true)
161+ }
162+ }
163+}
@@ -0,0 +1,163 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/usecase/keys"
30+ "github.com/bnema/vev/internal/usecase/mouse"
31+)
32+
33+func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
34+ ac.mouseScan.Scan(data,
35+ func(ev mouse.Event) { d.handleMouse(ac, ev) },
36+ func(b []byte) {
37+ if ac.pickerActive() {
38+ d.handlePickerInput(ac, b)
39+ return
40+ }
41+ if ac.copyModeActive() {
42+ d.handleCopyInput(ac, b)
43+ return
44+ }
45+ ac.keys.Route(b)
46+ },
47+ )
48+}
49+
50+func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
51+ if ac.pickerActive() {
52+ return
53+ }
54+ sess := ac.currentSession()
55+ if sess == nil {
56+ return
57+ }
58+ tb := sess.activeTab()
59+ if tb == nil {
60+ return
61+ }
62+
63+ if ac.copyModeActive() {
64+ switch ev.Button {
65+ case mouse.WheelUp:
66+ d.copyWheel(sess, ac, -3)
67+ case mouse.WheelDown:
68+ d.copyWheel(sess, ac, 3)
69+ }
70+ return
71+ }
72+
73+ tb.mu.Lock()
74+ childRows := tb.screen.Frame.Height
75+ mouseMode, mouseSGR := tb.screen.MouseMode()
76+ altScreen := tb.screen.AltScreenActive()
77+ tb.mu.Unlock()
78+
79+ if mouseMode != 0 {
80+ if !mouseSGR || ev.Row >= childRows {
81+ return
82+ }
83+ daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
84+ return
85+ }
86+
87+ switch ev.Button {
88+ case mouse.WheelUp:
89+ if altScreen {
90+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
91+ return
92+ }
93+ d.enterCopyMode(sess, ac)
94+ d.copyWheel(sess, ac, -3)
95+ case mouse.WheelDown:
96+ if altScreen {
97+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
98+ }
99+ }
100+}
101+
102+type daemonKeyHandler struct {
103+ d *Daemon
104+ ac *attachedClient
105+}
106+
107+func (h daemonKeyHandler) Forward(data []byte) {
108+ sess := h.ac.currentSession()
109+ if sess == nil {
110+ return
111+ }
112+ tb := sess.activeTab()
113+ if tb == nil {
114+ return
115+ }
116+ if _, err := tb.pty.Write(data); err != nil {
117+ h.d.log.Error("pty write failed", "err", err, "session", sess.name)
118+ }
119+}
120+
121+func (h daemonKeyHandler) Action(action keys.Action) {
122+ sess := h.ac.currentSession()
123+ if sess == nil {
124+ return
125+ }
126+ switch action {
127+ case keys.ActionCreateTab:
128+ if err := h.d.createTab(sess, h.ac.size); err != nil {
129+ h.d.log.Error("create tab failed", "err", err, "session", sess.name)
130+ return
131+ }
132+ h.d.paint(sess, h.ac, true)
133+ case keys.ActionNextTab:
134+ if sess.switchRelative(1) {
135+ h.d.paint(sess, h.ac, true)
136+ }
137+ case keys.ActionPreviousTab:
138+ if sess.switchRelative(-1) {
139+ h.d.paint(sess, h.ac, true)
140+ }
141+ case keys.ActionDetach:
142+ h.d.clientGone(sess, h.ac, true)
143+ case keys.ActionCloseTab:
144+ tb := sess.activeTab()
145+ if tb != nil {
146+ h.d.closeTab(sess, tb, true)
147+ }
148+ case keys.ActionCopyMode:
149+ h.d.enterCopyMode(sess, h.ac)
150+ case keys.ActionRenameSession:
151+ sess.promoteEphemeral()
152+ h.d.paint(sess, h.ac, true)
153+ case keys.ActionOpenPicker:
154+ h.d.enterPicker(sess, h.ac)
155+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
156+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
157+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
158+ idx := int(action - keys.ActionSwitchTab1)
159+ if sess.switchTab(idx) {
160+ h.d.paint(sess, h.ac, true)
161+ }
162+ }
163+}
A internal/usecase/daemon/picker.go +395 -0
@@ -0,0 +1,395 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "sort"
30+ "strconv"
31+
32+ "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/ports"
34+ "github.com/bnema/vev/internal/usecase/keys"
35+ "github.com/bnema/vev/internal/usecase/picker"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/pkg/renderer"
38+)
39+
40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
41+
42+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
43+ views, curTab := d.pickerViews(sess)
44+ ac.pickerMu.Lock()
45+ ac.picker = picker.New(views, sess.id, curTab)
46+ ac.pickerPending = nil
47+ ac.pickerPreview = nil
48+ ac.pickerMu.Unlock()
49+ d.registerPreviewForSelection(ac)
50+ d.paint(sess, ac, true)
51+}
52+
53+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
54+ d.mu.Lock()
55+ sessions := make([]*session, 0, len(d.sessions))
56+ for _, s := range d.sessions {
57+ sessions = append(sessions, s)
58+ }
59+ d.mu.Unlock()
60+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
61+
62+ views := make([]picker.SessionView, 0, len(sessions))
63+ curTab := 0
64+ for _, s := range sessions {
65+ s.mu.Lock()
66+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
67+ for i := range s.tabs {
68+ view.Tabs[i] = strconv.Itoa(i + 1)
69+ }
70+ if s == cur {
71+ curTab = s.active
72+ }
73+ s.mu.Unlock()
74+ views = append(views, view)
75+ }
76+ return views, curTab
77+}
78+
79+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
80+ sess := ac.currentSession()
81+ if sess == nil {
82+ return
83+ }
84+ ac.pickerMu.Lock()
85+ if ac.picker == nil {
86+ ac.pickerPending = nil
87+ d.stopPickerPendingTimerLocked(ac)
88+ ac.pickerMu.Unlock()
89+ return
90+ }
91+ if len(ac.pickerPending) > 0 {
92+ d.stopPickerPendingTimerLocked(ac)
93+ combined := make([]byte, 0, len(ac.pickerPending)+len(data))
94+ combined = append(combined, ac.pickerPending...)
95+ combined = append(combined, data...)
96+ data = combined
97+ ac.pickerPending = nil
98+ }
99+ changed := false
100+ exit := false
101+ switchTarget := false
102+ for i := 0; i < len(data); i++ {
103+ switch data[i] {
104+ case 'j':
105+ ac.picker.Down()
106+ changed = true
107+ case 'k':
108+ ac.picker.Up()
109+ changed = true
110+ case '\r', '\n':
111+ switchTarget = true
112+ exit = true
113+ case 'q', 0x03, 0x1b:
114+ if data[i] == 0x1b {
115+ tail := data[i:]
116+ consumed, ok := routePickerEscape(ac.picker, tail)
117+ if ok {
118+ i += consumed - 1
119+ changed = true
120+ continue
121+ }
122+ if len(tail) == 1 {
123+ d.retainPickerESCLocked(ac)
124+ break
125+ }
126+ if isPickerEscapePrefix(tail) {
127+ ac.pickerPending = append(ac.pickerPending[:0], tail...)
128+ break
129+ }
130+ }
131+ exit = true
132+ }
133+ }
134+ var target picker.Target
135+ var ok bool
136+ if switchTarget {
137+ target, ok = ac.picker.Selected()
138+ }
139+ ac.pickerMu.Unlock()
140+
141+ if changed {
142+ d.registerPreviewForSelection(ac)
143+ }
144+ if exit {
145+ d.closePicker(ac)
146+ }
147+ if switchTarget && ok {
148+ d.switchToTarget(sess, ac, target)
149+ return
150+ }
151+ if exit || changed {
152+ d.paint(sess, ac, true)
153+ }
154+}
155+
156+func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
157+ ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
158+ ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
159+ ac.pickerMu.Lock()
160+ if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
161+ ac.pickerMu.Unlock()
162+ return
163+ }
164+ ac.pickerPending = nil
165+ ac.pickerESC.timer = nil
166+ ac.pickerESC.done = nil
167+ ac.picker = nil
168+ ac.pickerMu.Unlock()
169+
170+ d.unregisterPreview(ac)
171+ if sess := ac.currentSession(); sess != nil {
172+ d.paint(sess, ac, true)
173+ }
174+ })
175+}
176+
177+func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
178+ ac.pickerESC.stop()
179+}
180+
181+func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
182+ if len(data) >= 3 && data[1] == '[' {
183+ switch data[2] {
184+ case 'A':
185+ m.Up()
186+ return 3, true
187+ case 'B':
188+ m.Down()
189+ return 3, true
190+ }
191+ }
192+ return 0, false
193+}
194+
195+func isPickerEscapePrefix(data []byte) bool {
196+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
197+}
198+
199+func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
200+ ac.pickerMu.Lock()
201+ var target picker.Target
202+ var ok bool
203+ if ac.picker != nil {
204+ target, ok = ac.picker.Selected()
205+ }
206+ ac.pickerMu.Unlock()
207+
208+ var next *tab
209+ if ok {
210+ next = d.tabByTarget(target)
211+ }
212+
213+ ac.pickerMu.Lock()
214+ old := ac.pickerPreview
215+ if ac.picker == nil {
216+ next = nil
217+ }
218+ ac.pickerPreview = next
219+ ac.pickerMu.Unlock()
220+
221+ if old != nil && old != next {
222+ old.mu.Lock()
223+ if old.previewClient == ac {
224+ old.previewClient = nil
225+ }
226+ old.mu.Unlock()
227+ }
228+ if next != nil {
229+ next.mu.Lock()
230+ next.previewClient = ac
231+ next.mu.Unlock()
232+
233+ ac.pickerMu.Lock()
234+ keep := ac.pickerPreview == next
235+ ac.pickerMu.Unlock()
236+ if !keep {
237+ next.mu.Lock()
238+ if next.previewClient == ac {
239+ next.previewClient = nil
240+ }
241+ next.mu.Unlock()
242+ }
243+ }
244+}
245+
246+func (d *Daemon) unregisterPreview(ac *attachedClient) {
247+ ac.pickerMu.Lock()
248+ old := ac.pickerPreview
249+ ac.pickerPreview = nil
250+ ac.pickerMu.Unlock()
251+
252+ if old != nil {
253+ old.mu.Lock()
254+ if old.previewClient == ac {
255+ old.previewClient = nil
256+ }
257+ old.mu.Unlock()
258+ }
259+}
260+
261+func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
262+ tb.mu.Lock()
263+ previewer := tb.previewClient
264+ tb.mu.Unlock()
265+ if previewer == nil {
266+ return
267+ }
268+ cleared := false
269+ previewer.pickerMu.Lock()
270+ if previewer.pickerPreview == tb {
271+ previewer.pickerPreview = nil
272+ cleared = true
273+ }
274+ previewer.pickerMu.Unlock()
275+ tb.mu.Lock()
276+ if tb.previewClient == previewer {
277+ tb.previewClient = nil
278+ }
279+ tb.mu.Unlock()
280+ if cleared {
281+ if sess := previewer.currentSession(); sess != nil {
282+ d.paint(sess, previewer, true)
283+ }
284+ }
285+}
286+
287+func (d *Daemon) closePicker(ac *attachedClient) {
288+ ac.pickerMu.Lock()
289+ ac.picker = nil
290+ ac.pickerPending = nil
291+ d.stopPickerPendingTimerLocked(ac)
292+ ac.pickerMu.Unlock()
293+ d.unregisterPreview(ac)
294+}
295+
296+func (d *Daemon) tabByTarget(target picker.Target) *tab {
297+ d.mu.Lock()
298+ sess := d.sessions[target.Session]
299+ d.mu.Unlock()
300+ if sess == nil {
301+ return nil
302+ }
303+ sess.mu.Lock()
304+ defer sess.mu.Unlock()
305+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
306+ return nil
307+ }
308+ return sess.tabs[target.TabIndex]
309+}
310+
311+func (d *Daemon) sessionByID(id domain.SessionID) *session {
312+ d.mu.Lock()
313+ defer d.mu.Unlock()
314+ return d.sessions[id]
315+}
316+
317+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
318+ targetSess := d.sessionByID(target.Session)
319+ if targetSess == nil {
320+ d.paint(sess, ac, true)
321+ return
322+ }
323+ if targetSess == sess {
324+ sess.switchTab(target.TabIndex)
325+ d.paint(sess, ac, true)
326+ return
327+ }
328+ old := d.stealClientForTarget(sess, ac, targetSess, target)
329+ if ac.currentSession() != targetSess {
330+ d.paint(sess, ac, true)
331+ return
332+ }
333+ if old != nil && old != ac {
334+ d.unregisterPreview(old)
335+ old.setSession(nil)
336+ d.notifyDetachedAsync(old, ports.ReasonDetach)
337+ }
338+ d.firstPaint(targetSess, ac, ac.size)
339+}
340+
341+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
342+ d.mu.Lock()
343+ if d.sessions[target.Session] != targetSess {
344+ d.mu.Unlock()
345+ return nil
346+ }
347+ from.mu.Lock()
348+ if from.client != ac {
349+ from.mu.Unlock()
350+ d.mu.Unlock()
351+ return nil
352+ }
353+ from.client = nil
354+ ac.setSession(nil)
355+ from.mu.Unlock()
356+
357+ targetSess.mu.Lock()
358+ old := targetSess.client
359+ targetSess.client = ac
360+ if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
361+ targetSess.active = target.TabIndex
362+ }
363+ targetSess.mu.Unlock()
364+ ac.setSession(targetSess)
365+ d.mu.Unlock()
366+ return old
367+}
368+
369+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
370+ inner := pickerModal.Composite(base, renderer.DefaultStyle())
371+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
372+ for y := range min(inner.Height, modalFrame.Height) {
373+ for x := range min(inner.Width, modalFrame.Width) {
374+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
375+ }
376+ }
377+ return base, []renderer.Damage{renderer.FullRedraw()}
378+}
379+
380+func snapshotPickerPreview(tb *tab) picker.Preview {
381+ if tb == nil {
382+ return picker.Preview{}
383+ }
384+ tb.mu.Lock()
385+ defer tb.mu.Unlock()
386+ return pickerPreviewFromLockedTab(tb)
387+}
388+
389+func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
390+ rows := make([][]renderer.Cell, tb.screen.Frame.Height)
391+ for y := range rows {
392+ rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
393+ }
394+ return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
395+}
@@ -0,0 +1,395 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "sort"
30+ "strconv"
31+
32+ "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/ports"
34+ "github.com/bnema/vev/internal/usecase/keys"
35+ "github.com/bnema/vev/internal/usecase/picker"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/pkg/renderer"
38+)
39+
40+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
41+
42+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
43+ views, curTab := d.pickerViews(sess)
44+ ac.pickerMu.Lock()
45+ ac.picker = picker.New(views, sess.id, curTab)
46+ ac.pickerPending = nil
47+ ac.pickerPreview = nil
48+ ac.pickerMu.Unlock()
49+ d.registerPreviewForSelection(ac)
50+ d.paint(sess, ac, true)
51+}
52+
53+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
54+ d.mu.Lock()
55+ sessions := make([]*session, 0, len(d.sessions))
56+ for _, s := range d.sessions {
57+ sessions = append(sessions, s)
58+ }
59+ d.mu.Unlock()
60+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
61+
62+ views := make([]picker.SessionView, 0, len(sessions))
63+ curTab := 0
64+ for _, s := range sessions {
65+ s.mu.Lock()
66+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
67+ for i := range s.tabs {
68+ view.Tabs[i] = strconv.Itoa(i + 1)
69+ }
70+ if s == cur {
71+ curTab = s.active
72+ }
73+ s.mu.Unlock()
74+ views = append(views, view)
75+ }
76+ return views, curTab
77+}
78+
79+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
80+ sess := ac.currentSession()
81+ if sess == nil {
82+ return
83+ }
84+ ac.pickerMu.Lock()
85+ if ac.picker == nil {
86+ ac.pickerPending = nil
87+ d.stopPickerPendingTimerLocked(ac)
88+ ac.pickerMu.Unlock()
89+ return
90+ }
91+ if len(ac.pickerPending) > 0 {
92+ d.stopPickerPendingTimerLocked(ac)
93+ combined := make([]byte, 0, len(ac.pickerPending)+len(data))
94+ combined = append(combined, ac.pickerPending...)
95+ combined = append(combined, data...)
96+ data = combined
97+ ac.pickerPending = nil
98+ }
99+ changed := false
100+ exit := false
101+ switchTarget := false
102+ for i := 0; i < len(data); i++ {
103+ switch data[i] {
104+ case 'j':
105+ ac.picker.Down()
106+ changed = true
107+ case 'k':
108+ ac.picker.Up()
109+ changed = true
110+ case '\r', '\n':
111+ switchTarget = true
112+ exit = true
113+ case 'q', 0x03, 0x1b:
114+ if data[i] == 0x1b {
115+ tail := data[i:]
116+ consumed, ok := routePickerEscape(ac.picker, tail)
117+ if ok {
118+ i += consumed - 1
119+ changed = true
120+ continue
121+ }
122+ if len(tail) == 1 {
123+ d.retainPickerESCLocked(ac)
124+ break
125+ }
126+ if isPickerEscapePrefix(tail) {
127+ ac.pickerPending = append(ac.pickerPending[:0], tail...)
128+ break
129+ }
130+ }
131+ exit = true
132+ }
133+ }
134+ var target picker.Target
135+ var ok bool
136+ if switchTarget {
137+ target, ok = ac.picker.Selected()
138+ }
139+ ac.pickerMu.Unlock()
140+
141+ if changed {
142+ d.registerPreviewForSelection(ac)
143+ }
144+ if exit {
145+ d.closePicker(ac)
146+ }
147+ if switchTarget && ok {
148+ d.switchToTarget(sess, ac, target)
149+ return
150+ }
151+ if exit || changed {
152+ d.paint(sess, ac, true)
153+ }
154+}
155+
156+func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
157+ ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
158+ ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
159+ ac.pickerMu.Lock()
160+ if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
161+ ac.pickerMu.Unlock()
162+ return
163+ }
164+ ac.pickerPending = nil
165+ ac.pickerESC.timer = nil
166+ ac.pickerESC.done = nil
167+ ac.picker = nil
168+ ac.pickerMu.Unlock()
169+
170+ d.unregisterPreview(ac)
171+ if sess := ac.currentSession(); sess != nil {
172+ d.paint(sess, ac, true)
173+ }
174+ })
175+}
176+
177+func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
178+ ac.pickerESC.stop()
179+}
180+
181+func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
182+ if len(data) >= 3 && data[1] == '[' {
183+ switch data[2] {
184+ case 'A':
185+ m.Up()
186+ return 3, true
187+ case 'B':
188+ m.Down()
189+ return 3, true
190+ }
191+ }
192+ return 0, false
193+}
194+
195+func isPickerEscapePrefix(data []byte) bool {
196+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
197+}
198+
199+func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
200+ ac.pickerMu.Lock()
201+ var target picker.Target
202+ var ok bool
203+ if ac.picker != nil {
204+ target, ok = ac.picker.Selected()
205+ }
206+ ac.pickerMu.Unlock()
207+
208+ var next *tab
209+ if ok {
210+ next = d.tabByTarget(target)
211+ }
212+
213+ ac.pickerMu.Lock()
214+ old := ac.pickerPreview
215+ if ac.picker == nil {
216+ next = nil
217+ }
218+ ac.pickerPreview = next
219+ ac.pickerMu.Unlock()
220+
221+ if old != nil && old != next {
222+ old.mu.Lock()
223+ if old.previewClient == ac {
224+ old.previewClient = nil
225+ }
226+ old.mu.Unlock()
227+ }
228+ if next != nil {
229+ next.mu.Lock()
230+ next.previewClient = ac
231+ next.mu.Unlock()
232+
233+ ac.pickerMu.Lock()
234+ keep := ac.pickerPreview == next
235+ ac.pickerMu.Unlock()
236+ if !keep {
237+ next.mu.Lock()
238+ if next.previewClient == ac {
239+ next.previewClient = nil
240+ }
241+ next.mu.Unlock()
242+ }
243+ }
244+}
245+
246+func (d *Daemon) unregisterPreview(ac *attachedClient) {
247+ ac.pickerMu.Lock()
248+ old := ac.pickerPreview
249+ ac.pickerPreview = nil
250+ ac.pickerMu.Unlock()
251+
252+ if old != nil {
253+ old.mu.Lock()
254+ if old.previewClient == ac {
255+ old.previewClient = nil
256+ }
257+ old.mu.Unlock()
258+ }
259+}
260+
261+func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
262+ tb.mu.Lock()
263+ previewer := tb.previewClient
264+ tb.mu.Unlock()
265+ if previewer == nil {
266+ return
267+ }
268+ cleared := false
269+ previewer.pickerMu.Lock()
270+ if previewer.pickerPreview == tb {
271+ previewer.pickerPreview = nil
272+ cleared = true
273+ }
274+ previewer.pickerMu.Unlock()
275+ tb.mu.Lock()
276+ if tb.previewClient == previewer {
277+ tb.previewClient = nil
278+ }
279+ tb.mu.Unlock()
280+ if cleared {
281+ if sess := previewer.currentSession(); sess != nil {
282+ d.paint(sess, previewer, true)
283+ }
284+ }
285+}
286+
287+func (d *Daemon) closePicker(ac *attachedClient) {
288+ ac.pickerMu.Lock()
289+ ac.picker = nil
290+ ac.pickerPending = nil
291+ d.stopPickerPendingTimerLocked(ac)
292+ ac.pickerMu.Unlock()
293+ d.unregisterPreview(ac)
294+}
295+
296+func (d *Daemon) tabByTarget(target picker.Target) *tab {
297+ d.mu.Lock()
298+ sess := d.sessions[target.Session]
299+ d.mu.Unlock()
300+ if sess == nil {
301+ return nil
302+ }
303+ sess.mu.Lock()
304+ defer sess.mu.Unlock()
305+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
306+ return nil
307+ }
308+ return sess.tabs[target.TabIndex]
309+}
310+
311+func (d *Daemon) sessionByID(id domain.SessionID) *session {
312+ d.mu.Lock()
313+ defer d.mu.Unlock()
314+ return d.sessions[id]
315+}
316+
317+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
318+ targetSess := d.sessionByID(target.Session)
319+ if targetSess == nil {
320+ d.paint(sess, ac, true)
321+ return
322+ }
323+ if targetSess == sess {
324+ sess.switchTab(target.TabIndex)
325+ d.paint(sess, ac, true)
326+ return
327+ }
328+ old := d.stealClientForTarget(sess, ac, targetSess, target)
329+ if ac.currentSession() != targetSess {
330+ d.paint(sess, ac, true)
331+ return
332+ }
333+ if old != nil && old != ac {
334+ d.unregisterPreview(old)
335+ old.setSession(nil)
336+ d.notifyDetachedAsync(old, ports.ReasonDetach)
337+ }
338+ d.firstPaint(targetSess, ac, ac.size)
339+}
340+
341+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
342+ d.mu.Lock()
343+ if d.sessions[target.Session] != targetSess {
344+ d.mu.Unlock()
345+ return nil
346+ }
347+ from.mu.Lock()
348+ if from.client != ac {
349+ from.mu.Unlock()
350+ d.mu.Unlock()
351+ return nil
352+ }
353+ from.client = nil
354+ ac.setSession(nil)
355+ from.mu.Unlock()
356+
357+ targetSess.mu.Lock()
358+ old := targetSess.client
359+ targetSess.client = ac
360+ if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
361+ targetSess.active = target.TabIndex
362+ }
363+ targetSess.mu.Unlock()
364+ ac.setSession(targetSess)
365+ d.mu.Unlock()
366+ return old
367+}
368+
369+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
370+ inner := pickerModal.Composite(base, renderer.DefaultStyle())
371+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
372+ for y := range min(inner.Height, modalFrame.Height) {
373+ for x := range min(inner.Width, modalFrame.Width) {
374+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
375+ }
376+ }
377+ return base, []renderer.Damage{renderer.FullRedraw()}
378+}
379+
380+func snapshotPickerPreview(tb *tab) picker.Preview {
381+ if tb == nil {
382+ return picker.Preview{}
383+ }
384+ tb.mu.Lock()
385+ defer tb.mu.Unlock()
386+ return pickerPreviewFromLockedTab(tb)
387+}
388+
389+func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
390+ rows := make([][]renderer.Cell, tb.screen.Frame.Height)
391+ for y := range rows {
392+ rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
393+ }
394+ return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
395+}
A internal/usecase/daemon/render.go +361 -0
@@ -0,0 +1,361 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "bytes"
30+ "strconv"
31+ "time"
32+
33+ scopy "github.com/bnema/vev/internal/usecase/copy"
34+ "github.com/bnema/vev/pkg/renderer"
35+ "github.com/bnema/vev/pkg/vt"
36+)
37+
38+func signal(ch chan struct{}) {
39+ select {
40+ case ch <- struct{}{}:
41+ default:
42+ }
43+}
44+
45+func (d *Daemon) ptyReader(sess *session, tb *tab) {
46+ defer d.sessWg.Done()
47+ buf := make([]byte, ptyReadBufSize)
48+ var resp []byte
49+ tb.mu.Lock()
50+ tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
51+ tb.mu.Unlock()
52+ for {
53+ n, err := tb.pty.Read(buf)
54+ if n > 0 {
55+ data := buf[:n]
56+ tb.mu.Lock()
57+ wasSyncing := tb.screen.SyncUpdateActive()
58+ tb.screen.Write(data)
59+ isSyncing := tb.screen.SyncUpdateActive()
60+ tb.mu.Unlock()
61+ if len(resp) > 0 {
62+ if _, writeErr := tb.pty.Write(resp); writeErr != nil {
63+ d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
64+ }
65+ resp = resp[:0]
66+ }
67+ if wasSyncing != isSyncing {
68+ tb.mu.Lock()
69+ tb.syncGen++
70+ gen := tb.syncGen
71+ tb.mu.Unlock()
72+ if isSyncing {
73+ go d.syncWatchdog(tb, gen)
74+ }
75+ }
76+ if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
77+ signal(tb.flush)
78+ continue
79+ }
80+ if isSyncing {
81+ continue
82+ }
83+ signal(tb.dirty)
84+ }
85+ if err != nil {
86+ d.closeTab(sess, tb, true)
87+ return
88+ }
89+ }
90+}
91+
92+// scheduler debounces dirty signals. The first dirty opens a short tab;
93+// sustained floods progressively widen that tab, while isolated updates
94+// return to the minimum delay for interactive latency.
95+func (d *Daemon) scheduler(sess *session, tb *tab) {
96+ defer d.sessWg.Done()
97+ delay := minDebounceInterval
98+ lastRender := d.clock.Now()
99+outer:
100+ for {
101+ select {
102+ case <-sess.ctx.Done():
103+ return
104+ case <-tabDone(tb):
105+ return
106+ case <-tb.flush:
107+ d.render(sess, tb)
108+ lastRender = d.clock.Now()
109+ continue
110+ case <-tb.dirty:
111+ if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
112+ delay = minDebounceInterval
113+ }
114+ }
115+
116+ coalesced := 0
117+ timer := d.clock.NewTimer(delay)
118+ absorb:
119+ for {
120+ select {
121+ case <-sess.ctx.Done():
122+ timer.Stop()
123+ return
124+ case <-tabDone(tb):
125+ timer.Stop()
126+ return
127+ case <-tb.flush:
128+ if !timer.Stop() {
129+ select {
130+ case <-timer.C():
131+ default:
132+ }
133+ }
134+ d.render(sess, tb)
135+ lastRender = d.clock.Now()
136+ continue outer
137+ case <-tb.dirty:
138+ coalesced++
139+ case <-timer.C():
140+ break absorb
141+ }
142+ }
143+ delay = nextDebounceDelay(delay, coalesced)
144+ d.render(sess, tb)
145+ lastRender = d.clock.Now()
146+ }
147+}
148+
149+func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
150+ if coalesced == 0 {
151+ return minDebounceInterval
152+ }
153+ if delay >= maxDebounceInterval {
154+ return maxDebounceInterval
155+ }
156+ delay += debounceStep
157+ if delay > maxDebounceInterval {
158+ return maxDebounceInterval
159+ }
160+ return delay
161+}
162+
163+func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
164+ timer := d.clock.NewTimer(maxSyncUpdateDuration)
165+ select {
166+ case <-tabDone(tb):
167+ timer.Stop()
168+ return
169+ case <-timer.C():
170+ }
171+
172+ tb.mu.Lock()
173+ if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
174+ tb.mu.Unlock()
175+ return
176+ }
177+ tb.screen.ForceSyncEnd()
178+ tb.mu.Unlock()
179+ signal(tb.flush)
180+}
181+
182+func tabDone(tb *tab) <-chan struct{} {
183+ if tb.ctx == nil {
184+ return nil
185+ }
186+ return tb.ctx.Done()
187+}
188+
189+func syncUpdateEndIn(data []byte) bool {
190+ return bytes.Contains(data, []byte("\x1b[?2026l"))
191+}
192+
193+// render paints the current client, or (when detached) just clears accumulated
194+// damage so it never grows unbounded while headless.
195+func (d *Daemon) render(sess *session, tb *tab) {
196+ tb.mu.Lock()
197+ if tb.screen.SyncUpdateActive() {
198+ tb.mu.Unlock()
199+ return
200+ }
201+ tb.mu.Unlock()
202+
203+ tb.mu.Lock()
204+ previewer := tb.previewClient
205+ tb.mu.Unlock()
206+
207+ sess.mu.Lock()
208+ ac := sess.client
209+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
210+ sess.mu.Unlock()
211+
212+ if previewer != nil {
213+ if previewSess := previewer.currentSession(); previewSess != nil {
214+ d.paint(previewSess, previewer, false)
215+ }
216+ if ac != nil && active && ac != previewer {
217+ d.paint(sess, ac, false)
218+ return
219+ }
220+ tb.mu.Lock()
221+ tb.screen.ClearDamage()
222+ tb.mu.Unlock()
223+ return
224+ }
225+
226+ if ac == nil || !active {
227+ tb.mu.Lock()
228+ tb.screen.ClearDamage()
229+ tb.mu.Unlock()
230+ return
231+ }
232+ d.paint(sess, ac, false)
233+}
234+
235+// paint draws the composed client frame (active tab plus status bar) and
236+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
237+// such as switch/create/close/rename/resize so the repaint is complete.
238+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
239+ tb := sess.activeTab()
240+ if tb == nil {
241+ return
242+ }
243+
244+ ac.sendMu.Lock()
245+ ac.copyMu.Lock()
246+ copyActive := ac.copyMode != nil
247+ var copyMode *scopy.Mode
248+ if ac.copyMode != nil {
249+ copyModeValue := *ac.copyMode
250+ copyMode = &copyModeValue
251+ }
252+ copyFeedback := ac.copyFeedback
253+ if copyFeedback != "" && !copyActive {
254+ ac.copyFeedback = ""
255+ }
256+ ac.copyMu.Unlock()
257+ ac.pickerMu.Lock()
258+ pickerActive := ac.picker != nil
259+ pickerModel := ac.picker
260+ previewTab := ac.pickerPreview
261+ preview := snapshotPickerPreview(previewTab)
262+
263+ tb.mu.Lock()
264+ if reset || copyActive || pickerActive {
265+ ac.rend.Reset()
266+ }
267+ if reset || pickerActive {
268+ ac.lastCursor.valid = false
269+ }
270+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
271+ if copyActive {
272+ frame, damage = composeCopyClientFrame(copyMode, tb)
273+ }
274+ if pickerActive {
275+ if previewTab == tb {
276+ preview = pickerPreviewFromLockedTab(tb)
277+ }
278+ frame, damage = composePickerClientFrame(pickerModel, preview, frame)
279+ }
280+ ac.pickerMu.Unlock()
281+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
282+ data, err := ac.rend.Draw(frame, damage)
283+ var cursorTail []byte
284+ if err == nil {
285+ cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
286+ }
287+ tb.screen.ClearDamage()
288+ tb.mu.Unlock()
289+
290+ var serr error
291+ if err == nil {
292+ data = append(data, cursorTail...)
293+ if len(data) > 0 {
294+ serr = ac.tr.Send(frameOutput(data))
295+ }
296+ }
297+ ac.sendMu.Unlock()
298+
299+ if err != nil {
300+ d.log.Error("render draw failed", "err", err, "session", sess.name)
301+ return
302+ }
303+ if serr != nil {
304+ d.detachOnSendError(sess, ac)
305+ }
306+}
307+
308+// resize applies a client size change to every tab using rows-1 for the PTY
309+// and VT screen, then resets the renderer shadow and repaints the composed
310+// client-sized frame (including the status row).
311+func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
312+ if copyActive || !s.CursorVisible() {
313+ return cursorOut{hidden: true}
314+ }
315+ style, ok := s.CursorStyle()
316+ if !ok {
317+ style = 5
318+ }
319+ return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
320+}
321+
322+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
323+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
324+ if !changed {
325+ return nil
326+ }
327+ prev := ac.lastCursor
328+ ac.lastCursor = desired
329+ ac.lastCursor.valid = true
330+ if desired.hidden {
331+ return []byte("\x1b[?25l")
332+ }
333+ var b []byte
334+ b = append(b, "\x1b["...)
335+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
336+ b = append(b, ';')
337+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
338+ b = append(b, 'H')
339+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
340+ b = append(b, "\x1b["...)
341+ b = strconv.AppendInt(b, int64(desired.style), 10)
342+ b = append(b, " q"...)
343+ }
344+ b = append(b, "\x1b[?25h"...)
345+ return b
346+}
347+
348+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
349+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
350+ frame := renderer.NewFrame(width, screenRows+1)
351+ for y := range screenRows {
352+ copy(frame.Row(y), tb.screen.Frame.Row(y))
353+ }
354+ drawStatus(frame.Row(screenRows), sess, rightStatus)
355+ if full {
356+ return frame, []renderer.Damage{renderer.FullRedraw()}
357+ }
358+ damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
359+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
360+ return frame, damage
361+}
@@ -0,0 +1,361 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "bytes"
30+ "strconv"
31+ "time"
32+
33+ scopy "github.com/bnema/vev/internal/usecase/copy"
34+ "github.com/bnema/vev/pkg/renderer"
35+ "github.com/bnema/vev/pkg/vt"
36+)
37+
38+func signal(ch chan struct{}) {
39+ select {
40+ case ch <- struct{}{}:
41+ default:
42+ }
43+}
44+
45+func (d *Daemon) ptyReader(sess *session, tb *tab) {
46+ defer d.sessWg.Done()
47+ buf := make([]byte, ptyReadBufSize)
48+ var resp []byte
49+ tb.mu.Lock()
50+ tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
51+ tb.mu.Unlock()
52+ for {
53+ n, err := tb.pty.Read(buf)
54+ if n > 0 {
55+ data := buf[:n]
56+ tb.mu.Lock()
57+ wasSyncing := tb.screen.SyncUpdateActive()
58+ tb.screen.Write(data)
59+ isSyncing := tb.screen.SyncUpdateActive()
60+ tb.mu.Unlock()
61+ if len(resp) > 0 {
62+ if _, writeErr := tb.pty.Write(resp); writeErr != nil {
63+ d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
64+ }
65+ resp = resp[:0]
66+ }
67+ if wasSyncing != isSyncing {
68+ tb.mu.Lock()
69+ tb.syncGen++
70+ gen := tb.syncGen
71+ tb.mu.Unlock()
72+ if isSyncing {
73+ go d.syncWatchdog(tb, gen)
74+ }
75+ }
76+ if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
77+ signal(tb.flush)
78+ continue
79+ }
80+ if isSyncing {
81+ continue
82+ }
83+ signal(tb.dirty)
84+ }
85+ if err != nil {
86+ d.closeTab(sess, tb, true)
87+ return
88+ }
89+ }
90+}
91+
92+// scheduler debounces dirty signals. The first dirty opens a short tab;
93+// sustained floods progressively widen that tab, while isolated updates
94+// return to the minimum delay for interactive latency.
95+func (d *Daemon) scheduler(sess *session, tb *tab) {
96+ defer d.sessWg.Done()
97+ delay := minDebounceInterval
98+ lastRender := d.clock.Now()
99+outer:
100+ for {
101+ select {
102+ case <-sess.ctx.Done():
103+ return
104+ case <-tabDone(tb):
105+ return
106+ case <-tb.flush:
107+ d.render(sess, tb)
108+ lastRender = d.clock.Now()
109+ continue
110+ case <-tb.dirty:
111+ if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
112+ delay = minDebounceInterval
113+ }
114+ }
115+
116+ coalesced := 0
117+ timer := d.clock.NewTimer(delay)
118+ absorb:
119+ for {
120+ select {
121+ case <-sess.ctx.Done():
122+ timer.Stop()
123+ return
124+ case <-tabDone(tb):
125+ timer.Stop()
126+ return
127+ case <-tb.flush:
128+ if !timer.Stop() {
129+ select {
130+ case <-timer.C():
131+ default:
132+ }
133+ }
134+ d.render(sess, tb)
135+ lastRender = d.clock.Now()
136+ continue outer
137+ case <-tb.dirty:
138+ coalesced++
139+ case <-timer.C():
140+ break absorb
141+ }
142+ }
143+ delay = nextDebounceDelay(delay, coalesced)
144+ d.render(sess, tb)
145+ lastRender = d.clock.Now()
146+ }
147+}
148+
149+func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
150+ if coalesced == 0 {
151+ return minDebounceInterval
152+ }
153+ if delay >= maxDebounceInterval {
154+ return maxDebounceInterval
155+ }
156+ delay += debounceStep
157+ if delay > maxDebounceInterval {
158+ return maxDebounceInterval
159+ }
160+ return delay
161+}
162+
163+func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
164+ timer := d.clock.NewTimer(maxSyncUpdateDuration)
165+ select {
166+ case <-tabDone(tb):
167+ timer.Stop()
168+ return
169+ case <-timer.C():
170+ }
171+
172+ tb.mu.Lock()
173+ if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
174+ tb.mu.Unlock()
175+ return
176+ }
177+ tb.screen.ForceSyncEnd()
178+ tb.mu.Unlock()
179+ signal(tb.flush)
180+}
181+
182+func tabDone(tb *tab) <-chan struct{} {
183+ if tb.ctx == nil {
184+ return nil
185+ }
186+ return tb.ctx.Done()
187+}
188+
189+func syncUpdateEndIn(data []byte) bool {
190+ return bytes.Contains(data, []byte("\x1b[?2026l"))
191+}
192+
193+// render paints the current client, or (when detached) just clears accumulated
194+// damage so it never grows unbounded while headless.
195+func (d *Daemon) render(sess *session, tb *tab) {
196+ tb.mu.Lock()
197+ if tb.screen.SyncUpdateActive() {
198+ tb.mu.Unlock()
199+ return
200+ }
201+ tb.mu.Unlock()
202+
203+ tb.mu.Lock()
204+ previewer := tb.previewClient
205+ tb.mu.Unlock()
206+
207+ sess.mu.Lock()
208+ ac := sess.client
209+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
210+ sess.mu.Unlock()
211+
212+ if previewer != nil {
213+ if previewSess := previewer.currentSession(); previewSess != nil {
214+ d.paint(previewSess, previewer, false)
215+ }
216+ if ac != nil && active && ac != previewer {
217+ d.paint(sess, ac, false)
218+ return
219+ }
220+ tb.mu.Lock()
221+ tb.screen.ClearDamage()
222+ tb.mu.Unlock()
223+ return
224+ }
225+
226+ if ac == nil || !active {
227+ tb.mu.Lock()
228+ tb.screen.ClearDamage()
229+ tb.mu.Unlock()
230+ return
231+ }
232+ d.paint(sess, ac, false)
233+}
234+
235+// paint draws the composed client frame (active tab plus status bar) and
236+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
237+// such as switch/create/close/rename/resize so the repaint is complete.
238+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
239+ tb := sess.activeTab()
240+ if tb == nil {
241+ return
242+ }
243+
244+ ac.sendMu.Lock()
245+ ac.copyMu.Lock()
246+ copyActive := ac.copyMode != nil
247+ var copyMode *scopy.Mode
248+ if ac.copyMode != nil {
249+ copyModeValue := *ac.copyMode
250+ copyMode = &copyModeValue
251+ }
252+ copyFeedback := ac.copyFeedback
253+ if copyFeedback != "" && !copyActive {
254+ ac.copyFeedback = ""
255+ }
256+ ac.copyMu.Unlock()
257+ ac.pickerMu.Lock()
258+ pickerActive := ac.picker != nil
259+ pickerModel := ac.picker
260+ previewTab := ac.pickerPreview
261+ preview := snapshotPickerPreview(previewTab)
262+
263+ tb.mu.Lock()
264+ if reset || copyActive || pickerActive {
265+ ac.rend.Reset()
266+ }
267+ if reset || pickerActive {
268+ ac.lastCursor.valid = false
269+ }
270+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
271+ if copyActive {
272+ frame, damage = composeCopyClientFrame(copyMode, tb)
273+ }
274+ if pickerActive {
275+ if previewTab == tb {
276+ preview = pickerPreviewFromLockedTab(tb)
277+ }
278+ frame, damage = composePickerClientFrame(pickerModel, preview, frame)
279+ }
280+ ac.pickerMu.Unlock()
281+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
282+ data, err := ac.rend.Draw(frame, damage)
283+ var cursorTail []byte
284+ if err == nil {
285+ cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
286+ }
287+ tb.screen.ClearDamage()
288+ tb.mu.Unlock()
289+
290+ var serr error
291+ if err == nil {
292+ data = append(data, cursorTail...)
293+ if len(data) > 0 {
294+ serr = ac.tr.Send(frameOutput(data))
295+ }
296+ }
297+ ac.sendMu.Unlock()
298+
299+ if err != nil {
300+ d.log.Error("render draw failed", "err", err, "session", sess.name)
301+ return
302+ }
303+ if serr != nil {
304+ d.detachOnSendError(sess, ac)
305+ }
306+}
307+
308+// resize applies a client size change to every tab using rows-1 for the PTY
309+// and VT screen, then resets the renderer shadow and repaints the composed
310+// client-sized frame (including the status row).
311+func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
312+ if copyActive || !s.CursorVisible() {
313+ return cursorOut{hidden: true}
314+ }
315+ style, ok := s.CursorStyle()
316+ if !ok {
317+ style = 5
318+ }
319+ return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
320+}
321+
322+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
323+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
324+ if !changed {
325+ return nil
326+ }
327+ prev := ac.lastCursor
328+ ac.lastCursor = desired
329+ ac.lastCursor.valid = true
330+ if desired.hidden {
331+ return []byte("\x1b[?25l")
332+ }
333+ var b []byte
334+ b = append(b, "\x1b["...)
335+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
336+ b = append(b, ';')
337+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
338+ b = append(b, 'H')
339+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
340+ b = append(b, "\x1b["...)
341+ b = strconv.AppendInt(b, int64(desired.style), 10)
342+ b = append(b, " q"...)
343+ }
344+ b = append(b, "\x1b[?25h"...)
345+ return b
346+}
347+
348+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
349+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
350+ frame := renderer.NewFrame(width, screenRows+1)
351+ for y := range screenRows {
352+ copy(frame.Row(y), tb.screen.Frame.Row(y))
353+ }
354+ drawStatus(frame.Row(screenRows), sess, rightStatus)
355+ if full {
356+ return frame, []renderer.Damage{renderer.FullRedraw()}
357+ }
358+ damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
359+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
360+ return frame, damage
361+}
A internal/usecase/daemon/session.go +321 -0
@@ -0,0 +1,321 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "context"
30+ "fmt"
31+ "strconv"
32+ "strings"
33+ "sync"
34+
35+ "github.com/bnema/vev/internal/domain"
36+ "github.com/bnema/vev/internal/ports"
37+ scopy "github.com/bnema/vev/internal/usecase/copy"
38+ "github.com/bnema/vev/pkg/vt"
39+)
40+
41+type session struct {
42+ id domain.SessionID
43+ name string
44+ ephemeral bool
45+
46+ ctx context.Context
47+ cancel context.CancelFunc
48+
49+ mu sync.Mutex // guards tabs, active, and client
50+ tabs []*tab
51+ active int
52+ client *attachedClient
53+ cwd string
54+}
55+
56+// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
57+// applies back-pressure by collapsing (a full buffer means "a render is
58+// already pending"), never by blocking the reader.
59+type tab struct {
60+ pty ports.PTY
61+ mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
62+ screen *vt.Screen
63+ scrollback *scopy.Scrollback
64+ dirty chan struct{}
65+ flush chan struct{}
66+ syncGen uint64
67+ // previewClient tracks the one client currently previewing this tab in the picker.
68+ // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
69+ previewClient *attachedClient
70+ size domain.Size
71+ ctx context.Context
72+ cancel context.CancelFunc
73+}
74+
75+// attachedClient is a client currently attached to a session's tab. rend is
76+// its private renderer shadow (so each client gets output minimised against
77+// what it has actually seen). sendMu serialises the two senders — the render
78+// scheduler and the connection handler — so the transport's single-writer
79+
80+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
81+ tbSize := tabSize(sz)
82+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
83+ if err != nil {
84+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
85+ }
86+
87+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
88+ d.nextID++
89+
90+ tb := newTab(pty, tbSize)
91+ sctx, cancel := context.WithCancel(d.serveCtx)
92+ tb.ctx, tb.cancel = context.WithCancel(sctx)
93+ sess := &session{
94+ id: id,
95+ name: name,
96+ ephemeral: ephemeral,
97+ ctx: sctx,
98+ cancel: cancel,
99+ tabs: []*tab{tb},
100+ cwd: cwd,
101+ }
102+ d.sessions[id] = sess
103+ d.startTabGoroutines(sess, tb)
104+ return sess, nil
105+}
106+
107+func (d *Daemon) createTab(sess *session, sz domain.Size) error {
108+ tbSize := tabSize(sz)
109+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
110+ if err != nil {
111+ return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
112+ }
113+ tb := newTab(pty, tbSize)
114+ tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
115+ sess.mu.Lock()
116+ sess.tabs = append(sess.tabs, tb)
117+ sess.active = len(sess.tabs) - 1
118+ sess.mu.Unlock()
119+ d.startTabGoroutines(sess, tb)
120+ return nil
121+}
122+
123+func newTab(pty ports.PTY, sz domain.Size) *tab {
124+ sb := scopy.NewScrollback(defaultScrollbackRows)
125+ screen := vt.NewScreen(sz.Cols, sz.Rows)
126+ screen.OnLineEvicted = sb.Append
127+ return &tab{
128+ pty: pty,
129+ screen: screen,
130+ scrollback: sb,
131+ dirty: make(chan struct{}, 1),
132+ flush: make(chan struct{}, 1),
133+ size: sz,
134+ }
135+}
136+
137+func tabSize(clientSize domain.Size) domain.Size {
138+ if !clientSize.Valid() {
139+ clientSize = defaultSize
140+ }
141+ rows := max(clientSize.Rows-1, 1)
142+ return domain.Size{Cols: clientSize.Cols, Rows: rows}
143+}
144+
145+func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
146+ d.sessWg.Add(2)
147+ go d.ptyReader(sess, tb)
148+ go d.scheduler(sess, tb)
149+}
150+
151+// attachClient makes ac the session's current client, displacing any prior one
152+// (which is notified with ReasonDetach and disconnected — its own conn handler
153+
154+func (s *session) detachIfCurrent(ac *attachedClient) bool {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ if s.client == ac {
158+ s.client = nil
159+ ac.setSession(nil)
160+ return true
161+ }
162+ return false
163+}
164+
165+func (s *session) activeTab() *tab {
166+ s.mu.Lock()
167+ defer s.mu.Unlock()
168+ if s.active < 0 || s.active >= len(s.tabs) {
169+ return nil
170+ }
171+ return s.tabs[s.active]
172+}
173+
174+func (s *session) switchTab(idx int) bool {
175+ s.mu.Lock()
176+ defer s.mu.Unlock()
177+ if idx < 0 || idx >= len(s.tabs) || idx == s.active {
178+ return false
179+ }
180+ s.active = idx
181+ return true
182+}
183+
184+func (s *session) switchRelative(delta int) bool {
185+ s.mu.Lock()
186+ defer s.mu.Unlock()
187+ if len(s.tabs) < 2 {
188+ return false
189+ }
190+ s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
191+ return true
192+}
193+
194+// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
195+// the current ephemeral numeric name is kept and the session is made persistent.
196+func (s *session) promoteEphemeral() {
197+ s.mu.Lock()
198+ defer s.mu.Unlock()
199+ s.ephemeral = false
200+}
201+
202+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
203+ sess.mu.Lock()
204+ idx := -1
205+ for i, w := range sess.tabs {
206+ if w == tb {
207+ idx = i
208+ break
209+ }
210+ }
211+ if idx == -1 {
212+ sess.mu.Unlock()
213+ return
214+ }
215+ if len(sess.tabs) == 1 {
216+ sess.mu.Unlock()
217+ d.killSession(sess, ports.ReasonSessionKilled)
218+ return
219+ }
220+ sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
221+ if sess.active >= len(sess.tabs) {
222+ sess.active = len(sess.tabs) - 1
223+ } else if idx < sess.active {
224+ sess.active--
225+ }
226+ ac := sess.client
227+ sess.mu.Unlock()
228+
229+ d.clearDestroyedTabPreview(tb)
230+ if tb.cancel != nil {
231+ tb.cancel()
232+ }
233+ _ = tb.pty.Close()
234+ if repaint && ac != nil {
235+ d.paint(sess, ac, true)
236+ }
237+}
238+
239+// ptyReader drains child output into the VT screen and pokes the dirty channel
240+// (non-blocking: a full channel already means a render is pending). On any read
241+
242+func (d *Daemon) killSession(sess *session, reason uint8) {
243+ d.mu.Lock()
244+ if _, ok := d.sessions[sess.id]; !ok {
245+ d.mu.Unlock()
246+ return
247+ }
248+ delete(d.sessions, sess.id)
249+ empty := len(d.sessions) == 0
250+ if empty {
251+ // Shutdown is now inevitable and irreversible (doneOnce below): stop
252+ // route from inserting new sessions from this instant, while we still
253+ // hold the same lock route checks under.
254+ d.closing = true
255+ }
256+ d.mu.Unlock()
257+
258+ sess.mu.Lock()
259+ ac := sess.client
260+ sess.client = nil
261+ sess.mu.Unlock()
262+ if ac != nil {
263+ d.unregisterPreview(ac)
264+ ac.setSession(nil)
265+ }
266+
267+ sess.cancel()
268+ sess.mu.Lock()
269+ tabs := append([]*tab(nil), sess.tabs...)
270+ sess.mu.Unlock()
271+ for _, tb := range tabs {
272+ d.clearDestroyedTabPreview(tb)
273+ _ = tb.pty.Close()
274+ }
275+ if empty {
276+ d.doneOnce.Do(func() { close(d.done) })
277+ }
278+
279+ if ac != nil {
280+ d.notifyDetachedAsync(ac, reason)
281+ }
282+}
283+
284+// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
285+// d.mu.
286+func (d *Daemon) allocEphemeralNameLocked() string {
287+ used := make(map[string]struct{}, len(d.sessions))
288+ for _, s := range d.sessions {
289+ used[s.name] = struct{}{}
290+ }
291+ for i := 0; ; i++ {
292+ n := strconv.Itoa(i)
293+ if _, taken := used[n]; !taken {
294+ return n
295+ }
296+ }
297+}
298+
299+func (d *Daemon) findByNameLocked(name string) *session {
300+ for _, s := range d.sessions {
301+ if s.name == name {
302+ return s
303+ }
304+ }
305+ return nil
306+}
307+
308+func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
309+
310+// childEnv builds the session child's environment: the daemon's own, with TERM
311+// and VEV forced to well-known values.
312+func (d *Daemon) childEnv(name string) []string {
313+ out := make([]string, 0, len(d.baseEnv)+2)
314+ for _, e := range d.baseEnv {
315+ if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
316+ continue
317+ }
318+ out = append(out, e)
319+ }
320+ return append(out, "TERM=xterm-256color", "VEV="+name)
321+}
@@ -0,0 +1,321 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "context"
30+ "fmt"
31+ "strconv"
32+ "strings"
33+ "sync"
34+
35+ "github.com/bnema/vev/internal/domain"
36+ "github.com/bnema/vev/internal/ports"
37+ scopy "github.com/bnema/vev/internal/usecase/copy"
38+ "github.com/bnema/vev/pkg/vt"
39+)
40+
41+type session struct {
42+ id domain.SessionID
43+ name string
44+ ephemeral bool
45+
46+ ctx context.Context
47+ cancel context.CancelFunc
48+
49+ mu sync.Mutex // guards tabs, active, and client
50+ tabs []*tab
51+ active int
52+ client *attachedClient
53+ cwd string
54+}
55+
56+// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
57+// applies back-pressure by collapsing (a full buffer means "a render is
58+// already pending"), never by blocking the reader.
59+type tab struct {
60+ pty ports.PTY
61+ mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
62+ screen *vt.Screen
63+ scrollback *scopy.Scrollback
64+ dirty chan struct{}
65+ flush chan struct{}
66+ syncGen uint64
67+ // previewClient tracks the one client currently previewing this tab in the picker.
68+ // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
69+ previewClient *attachedClient
70+ size domain.Size
71+ ctx context.Context
72+ cancel context.CancelFunc
73+}
74+
75+// attachedClient is a client currently attached to a session's tab. rend is
76+// its private renderer shadow (so each client gets output minimised against
77+// what it has actually seen). sendMu serialises the two senders — the render
78+// scheduler and the connection handler — so the transport's single-writer
79+
80+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
81+ tbSize := tabSize(sz)
82+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
83+ if err != nil {
84+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
85+ }
86+
87+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
88+ d.nextID++
89+
90+ tb := newTab(pty, tbSize)
91+ sctx, cancel := context.WithCancel(d.serveCtx)
92+ tb.ctx, tb.cancel = context.WithCancel(sctx)
93+ sess := &session{
94+ id: id,
95+ name: name,
96+ ephemeral: ephemeral,
97+ ctx: sctx,
98+ cancel: cancel,
99+ tabs: []*tab{tb},
100+ cwd: cwd,
101+ }
102+ d.sessions[id] = sess
103+ d.startTabGoroutines(sess, tb)
104+ return sess, nil
105+}
106+
107+func (d *Daemon) createTab(sess *session, sz domain.Size) error {
108+ tbSize := tabSize(sz)
109+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
110+ if err != nil {
111+ return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
112+ }
113+ tb := newTab(pty, tbSize)
114+ tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
115+ sess.mu.Lock()
116+ sess.tabs = append(sess.tabs, tb)
117+ sess.active = len(sess.tabs) - 1
118+ sess.mu.Unlock()
119+ d.startTabGoroutines(sess, tb)
120+ return nil
121+}
122+
123+func newTab(pty ports.PTY, sz domain.Size) *tab {
124+ sb := scopy.NewScrollback(defaultScrollbackRows)
125+ screen := vt.NewScreen(sz.Cols, sz.Rows)
126+ screen.OnLineEvicted = sb.Append
127+ return &tab{
128+ pty: pty,
129+ screen: screen,
130+ scrollback: sb,
131+ dirty: make(chan struct{}, 1),
132+ flush: make(chan struct{}, 1),
133+ size: sz,
134+ }
135+}
136+
137+func tabSize(clientSize domain.Size) domain.Size {
138+ if !clientSize.Valid() {
139+ clientSize = defaultSize
140+ }
141+ rows := max(clientSize.Rows-1, 1)
142+ return domain.Size{Cols: clientSize.Cols, Rows: rows}
143+}
144+
145+func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
146+ d.sessWg.Add(2)
147+ go d.ptyReader(sess, tb)
148+ go d.scheduler(sess, tb)
149+}
150+
151+// attachClient makes ac the session's current client, displacing any prior one
152+// (which is notified with ReasonDetach and disconnected — its own conn handler
153+
154+func (s *session) detachIfCurrent(ac *attachedClient) bool {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ if s.client == ac {
158+ s.client = nil
159+ ac.setSession(nil)
160+ return true
161+ }
162+ return false
163+}
164+
165+func (s *session) activeTab() *tab {
166+ s.mu.Lock()
167+ defer s.mu.Unlock()
168+ if s.active < 0 || s.active >= len(s.tabs) {
169+ return nil
170+ }
171+ return s.tabs[s.active]
172+}
173+
174+func (s *session) switchTab(idx int) bool {
175+ s.mu.Lock()
176+ defer s.mu.Unlock()
177+ if idx < 0 || idx >= len(s.tabs) || idx == s.active {
178+ return false
179+ }
180+ s.active = idx
181+ return true
182+}
183+
184+func (s *session) switchRelative(delta int) bool {
185+ s.mu.Lock()
186+ defer s.mu.Unlock()
187+ if len(s.tabs) < 2 {
188+ return false
189+ }
190+ s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
191+ return true
192+}
193+
194+// promoteEphemeral is M3's promptless Alt+r behavior: without a naming UI yet,
195+// the current ephemeral numeric name is kept and the session is made persistent.
196+func (s *session) promoteEphemeral() {
197+ s.mu.Lock()
198+ defer s.mu.Unlock()
199+ s.ephemeral = false
200+}
201+
202+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
203+ sess.mu.Lock()
204+ idx := -1
205+ for i, w := range sess.tabs {
206+ if w == tb {
207+ idx = i
208+ break
209+ }
210+ }
211+ if idx == -1 {
212+ sess.mu.Unlock()
213+ return
214+ }
215+ if len(sess.tabs) == 1 {
216+ sess.mu.Unlock()
217+ d.killSession(sess, ports.ReasonSessionKilled)
218+ return
219+ }
220+ sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
221+ if sess.active >= len(sess.tabs) {
222+ sess.active = len(sess.tabs) - 1
223+ } else if idx < sess.active {
224+ sess.active--
225+ }
226+ ac := sess.client
227+ sess.mu.Unlock()
228+
229+ d.clearDestroyedTabPreview(tb)
230+ if tb.cancel != nil {
231+ tb.cancel()
232+ }
233+ _ = tb.pty.Close()
234+ if repaint && ac != nil {
235+ d.paint(sess, ac, true)
236+ }
237+}
238+
239+// ptyReader drains child output into the VT screen and pokes the dirty channel
240+// (non-blocking: a full channel already means a render is pending). On any read
241+
242+func (d *Daemon) killSession(sess *session, reason uint8) {
243+ d.mu.Lock()
244+ if _, ok := d.sessions[sess.id]; !ok {
245+ d.mu.Unlock()
246+ return
247+ }
248+ delete(d.sessions, sess.id)
249+ empty := len(d.sessions) == 0
250+ if empty {
251+ // Shutdown is now inevitable and irreversible (doneOnce below): stop
252+ // route from inserting new sessions from this instant, while we still
253+ // hold the same lock route checks under.
254+ d.closing = true
255+ }
256+ d.mu.Unlock()
257+
258+ sess.mu.Lock()
259+ ac := sess.client
260+ sess.client = nil
261+ sess.mu.Unlock()
262+ if ac != nil {
263+ d.unregisterPreview(ac)
264+ ac.setSession(nil)
265+ }
266+
267+ sess.cancel()
268+ sess.mu.Lock()
269+ tabs := append([]*tab(nil), sess.tabs...)
270+ sess.mu.Unlock()
271+ for _, tb := range tabs {
272+ d.clearDestroyedTabPreview(tb)
273+ _ = tb.pty.Close()
274+ }
275+ if empty {
276+ d.doneOnce.Do(func() { close(d.done) })
277+ }
278+
279+ if ac != nil {
280+ d.notifyDetachedAsync(ac, reason)
281+ }
282+}
283+
284+// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
285+// d.mu.
286+func (d *Daemon) allocEphemeralNameLocked() string {
287+ used := make(map[string]struct{}, len(d.sessions))
288+ for _, s := range d.sessions {
289+ used[s.name] = struct{}{}
290+ }
291+ for i := 0; ; i++ {
292+ n := strconv.Itoa(i)
293+ if _, taken := used[n]; !taken {
294+ return n
295+ }
296+ }
297+}
298+
299+func (d *Daemon) findByNameLocked(name string) *session {
300+ for _, s := range d.sessions {
301+ if s.name == name {
302+ return s
303+ }
304+ }
305+ return nil
306+}
307+
308+func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
309+
310+// childEnv builds the session child's environment: the daemon's own, with TERM
311+// and VEV forced to well-known values.
312+func (d *Daemon) childEnv(name string) []string {
313+ out := make([]string, 0, len(d.baseEnv)+2)
314+ for _, e := range d.baseEnv {
315+ if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
316+ continue
317+ }
318+ out = append(out, e)
319+ }
320+ return append(out, "TERM=xterm-256color", "VEV="+name)
321+}
A internal/usecase/daemon/status.go +89 -0
@@ -0,0 +1,89 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/pkg/renderer"
32+)
33+
34+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
35+ for i := range row {
36+ row[i] = renderer.BlankCell()
37+ }
38+ status := sess.statusSegments()
39+ x := 0
40+ writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
41+ for _, w := range status.tabs {
42+ style := renderer.DefaultStyle()
43+ if w.active {
44+ style.Inverse = true
45+ }
46+ writeStatusText(row, &x, " "+w.name+" ", style)
47+ }
48+ if rightText == "" {
49+ return
50+ }
51+ style := renderer.DefaultStyle()
52+ rightWidth := len([]rune(rightText)) + 1
53+ if len(row)-x-1 < rightWidth {
54+ return
55+ }
56+ x = len(row) - rightWidth
57+ writeStatusText(row, &x, " "+rightText, style)
58+}
59+
60+type statusSnapshot struct {
61+ session string
62+ tabs []statusTab
63+}
64+
65+type statusTab struct {
66+ name string
67+ active bool
68+}
69+
70+func (s *session) statusSegments() statusSnapshot {
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
74+ for i := range s.tabs {
75+ name := strconv.Itoa(i + 1)
76+ snap.tabs[i] = statusTab{name: name, active: i == s.active}
77+ }
78+ return snap
79+}
80+
81+func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
82+ for _, r := range text {
83+ if *x >= len(row) {
84+ return
85+ }
86+ row[*x] = renderer.Cell{Rune: r, Style: style}
87+ (*x)++
88+ }
89+}
@@ -0,0 +1,89 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "strconv"
30+
31+ "github.com/bnema/vev/pkg/renderer"
32+)
33+
34+func drawStatus(row []renderer.Cell, sess *session, rightText string) {
35+ for i := range row {
36+ row[i] = renderer.BlankCell()
37+ }
38+ status := sess.statusSegments()
39+ x := 0
40+ writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
41+ for _, w := range status.tabs {
42+ style := renderer.DefaultStyle()
43+ if w.active {
44+ style.Inverse = true
45+ }
46+ writeStatusText(row, &x, " "+w.name+" ", style)
47+ }
48+ if rightText == "" {
49+ return
50+ }
51+ style := renderer.DefaultStyle()
52+ rightWidth := len([]rune(rightText)) + 1
53+ if len(row)-x-1 < rightWidth {
54+ return
55+ }
56+ x = len(row) - rightWidth
57+ writeStatusText(row, &x, " "+rightText, style)
58+}
59+
60+type statusSnapshot struct {
61+ session string
62+ tabs []statusTab
63+}
64+
65+type statusTab struct {
66+ name string
67+ active bool
68+}
69+
70+func (s *session) statusSegments() statusSnapshot {
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
74+ for i := range s.tabs {
75+ name := strconv.Itoa(i + 1)
76+ snap.tabs[i] = statusTab{name: name, active: i == s.active}
77+ }
78+ return snap
79+}
80+
81+func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
82+ for _, r := range text {
83+ if *x >= len(row) {
84+ return
85+ }
86+ row[*x] = renderer.Cell{Rune: r, Style: style}
87+ (*x)++
88+ }
89+}
A internal/usecase/daemon/wire.go +58 -0
@@ -0,0 +1,58 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/ports"
30+)
31+
32+func frameWelcome(s *session) ports.Frame {
33+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
34+ SessionID: string(s.id),
35+ SessionName: s.name,
36+ Ephemeral: s.ephemeral,
37+ })}
38+}
39+
40+func frameError(code uint16, text string) ports.Frame {
41+ return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
42+}
43+
44+func frameOutput(b []byte) ports.Frame {
45+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
46+}
47+
48+func frameDetached(reason uint8) ports.Frame {
49+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
50+}
51+
52+func framePong() ports.Frame {
53+ return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
54+}
55+
56+func frameSessions(infos []ports.SessionInfo) ports.Frame {
57+ return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
58+}
@@ -0,0 +1,58 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+//
5+// Concurrency model (sessions own one or more PTY-backed tabs):
6+//
7+// - Serve runs the accept loop. Each accepted connection is handled by its
8+// own goroutine (handleConn): it reads the first frame and routes it to a
9+// session create/attach, a list, or a kill.
10+// - Per session there are exactly two long-lived goroutines: the PTY reader
11+// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12+// and the render scheduler (debounces dirties and paints the attached
13+// client). Both are tied to the session context and unwind when the
14+// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15+// scheduler).
16+// - The daemon exits (Serve returns) when the last session is removed, or
17+// when the parent context is cancelled (graceful shutdown notifies any
18+// attached clients with ReasonServerShutdown).
19+//
20+// Locking: a session's screen and per-client renderer shadow are both guarded
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22+// Daemon.mu. When more than one is held the order is always
23+// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25+// a slow client.
26+package daemon
27+
28+import (
29+ "github.com/bnema/vev/internal/ports"
30+)
31+
32+func frameWelcome(s *session) ports.Frame {
33+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
34+ SessionID: string(s.id),
35+ SessionName: s.name,
36+ Ephemeral: s.ephemeral,
37+ })}
38+}
39+
40+func frameError(code uint16, text string) ports.Frame {
41+ return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
42+}
43+
44+func frameOutput(b []byte) ports.Frame {
45+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
46+}
47+
48+func frameDetached(reason uint8) ports.Frame {
49+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
50+}
51+
52+func framePong() ports.Frame {
53+ return ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}
54+}
55+
56+func frameSessions(infos []ports.SessionInfo) ports.Frame {
57+ return ports.Frame{Type: ports.MsgSessions, Payload: ports.MarshalSessions(ports.Sessions{Sessions: infos})}
58+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close