1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "bytes"
6
7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"
11 themeui "github.com/bnema/vev/internal/usecase/theme"
12)
13
14// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
15// PTY read. The VT's final state is inactive in this case, so it cannot be
16// inferred from before/after SyncUpdateActive alone.
17func completedSynchronizedUpdate(data []byte) bool {
18 _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
19 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
20}
21
22// paneRenderable reports dynamic composition visibility. Callers must not
23// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
24func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
25 if sess == nil || tb == nil || p == nil {
26 return false
27 }
28 sess.mu.Lock()
29 active := sess.attachmentViewsTabLocked(tb)
30 attached := len(sess.attachments) != 0
31 sess.mu.Unlock()
32
33 if !active || !attached {
34 // A headless tab is still renderable while a preview subscriber
35 // watches it: the preview service composes its frames from this tab.
36 rc := sess.renderCoordinator()
37 if rc == nil || !rc.hasPreviewSubscribers() {
38 return false
39 }
40 }
41 tb.mu.Lock()
42 defer tb.mu.Unlock()
43 if tb.floating.pane == p {
44 return active && attached && tb.floating.state == floatingVisible
45 }
46 placements, ok := solvedPlacementsLocked(tb)
47 if !ok {
48 return false
49 }
50 for _, placement := range placements {
51 if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
52 return true
53 }
54 }
55 return false
56}
57
58// ptyReader is retained as a compatibility entry point for focused tests. The
59// production goroutine calls readPanePTY, whose closure owns only the pane.
60func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
61 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
62 publishPaneOwner(p, sess, tb, 0)
63 }
64 d.readPanePTY(p)
65}
66
67func (d *Daemon) readPanePTY(p *pane) {
68 defer d.sessWg.Done()
69 if p == nil {
70 return
71 }
72 buf := make([]byte, ptyReadBufSize)
73 p.mu.Lock()
74 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
75 p.screen.OnBell = func() { p.ptyAttention = true }
76 p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
77 p.screen.OnProgress = func(bool) { p.ptyAttention = true }
78 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
79 p.mu.Unlock()
80 for {
81 n, err := p.pty.Read(buf)
82 if n > 0 {
83 d.processPanePTYData(p, buf[:n], true)
84 }
85 if err != nil {
86 lease := p.effectLease()
87 if sessionName, current := paneEffectSessionName(lease); current {
88 d.log.Info("pane pty closed", "err", err, "session", sessionName)
89 } else {
90 d.log.Info("pane pty closed", "err", err)
91 }
92 if p.onExit != nil {
93 p.onExit()
94 } else {
95 d.reapPaneOwner(p)
96 }
97 return
98 }
99 }
100}
101
102type panePTYEffects struct {
103 lease paneEffectLease
104 renderCoordinator *renderCoordinator
105 wasSyncing bool
106 isSyncing bool
107 completeSyncRead bool
108 syncEnded bool
109 attention bool
110 responses []byte
111 clipboards []string
112 syncCleanup syncTimerCleanup
113}
114
115// processPTYData retains the old call shape for resize replay and focused tests,
116// but routing is always derived from the pane owner captured under pane.mu.
117func (d *Daemon) processPTYData(_ *session, _ *tab, p *pane, data []byte, bufferDuringApply bool) {
118 d.processPanePTYData(p, data, bufferDuringApply)
119}
120
121// processPanePTYData is the sole VT parsing path. While a resize apply owns the
122// pane, reader calls append exact bytes and returns immediately; it never waits
123// for PTY.Resize or loses a short read.
124func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
125 if p == nil {
126 return
127 }
128 p.mu.Lock()
129 if bufferDuringApply && p.resizeApplying {
130 p.resizePending = append(p.resizePending, data...)
131 p.mu.Unlock()
132 return
133 }
134 effects := panePTYEffects{lease: p.effectLeaseLocked()}
135 owner := effects.lease.owner
136 if owner != nil && owner.session != nil {
137 effects.renderCoordinator = owner.session.renderCoordinator()
138 }
139 effects.wasSyncing = p.screen.SyncUpdateActive()
140 writePaneScreenLocked(p, data)
141 p.refreshTerminalTitleLocked()
142 effects.isSyncing = p.screen.SyncUpdateActive()
143 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
144 if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
145 if effects.isSyncing {
146 syncGen := owner.session.syncGen.Add(1)
147 p.syncGen = syncGen
148 if effects.renderCoordinator != nil {
149 lease := effects.lease
150 effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
151 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
152 }, func() {
153 p.mu.Lock()
154 if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
155 p.screen.ForceSyncEnd()
156 }
157 p.mu.Unlock()
158 })
159 }
160 } else {
161 effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
162 }
163 }
164 effects.attention = p.ptyAttention
165 p.ptyAttention = false
166 effects.responses = append([]byte(nil), p.ptyResponses...)
167 effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses)
168 p.ptyResponses = p.ptyResponses[:0]
169 effects.clipboards = append([]string(nil), p.ptyClipboards...)
170 p.ptyClipboards = p.ptyClipboards[:0]
171 p.mu.Unlock()
172 effects.syncCleanup.finish()
173 d.applyPanePTYEffects(p, effects)
174}
175
176// filterUnsupportedKittyAnimationResponses keeps older icat releases from
177// leaving vev's static-animation fallback replies in the shared PTY input
178// queue. Current Kitty sends animation controls with q=2, but older releases
179// requested error replies and then exited without consuming them. Transfer
180// capability probes use ordinary image IDs and retain their ENOTSUP replies.
181func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
182 if !bytes.Contains(data, []byte("i=0,I=")) || !bytes.Contains(data, []byte(";ENOTSUP")) {
183 return data
184 }
185 out := make([]byte, 0, len(data))
186 for len(data) != 0 {
187 start := bytes.Index(data, []byte("\x1b_G"))
188 if start < 0 {
189 out = append(out, data...)
190 break
191 }
192 out = append(out, data[:start]...)
193 endOffset := bytes.Index(data[start+3:], []byte("\x1b\\"))
194 if endOffset < 0 {
195 out = append(out, data[start:]...)
196 break
197 }
198 end := start + 3 + endOffset + 2
199 record := data[start:end]
200 body := record[3 : len(record)-2]
201 if !bytes.Contains(body, []byte("i=0,I=")) || !bytes.HasSuffix(body, []byte(";ENOTSUP")) {
202 out = append(out, record...)
203 }
204 data = data[end:]
205 }
206 return out
207}
208
209// applyPanePTYEffects emits only effects still bound to the owner generation
210// that parsed them. Each owner-sensitive boundary revalidates independently so
211// ownership publication can retire the remaining effects without rerouting
212// bytes that belonged to the old owner.
213func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
214 owner := effects.lease.owner
215 if owner == nil || owner.session == nil || owner.tab == nil {
216 return
217 }
218 if effects.lease.Current() {
219 markSnapshotDirty(owner.session)
220 }
221 if effects.attention && effects.lease.Current() {
222 d.noteAttention(owner.session, owner.tab)
223 }
224 for _, b64 := range effects.clipboards {
225 if effects.lease.Current() {
226 d.forwardClipboardAsync(effects.lease, b64)
227 }
228 }
229 if len(effects.responses) > 0 && effects.lease.Current() {
230 if _, err := p.pty.Write(effects.responses); err != nil {
231 if sessionName, current := paneEffectSessionName(effects.lease); current {
232 d.log.Warn("pty response write failed", "err", err, "session", sessionName)
233 }
234 }
235 }
236 if effects.renderCoordinator == nil || !effects.lease.Current() {
237 return
238 }
239 renderable := d.paneRenderable(owner.session, owner.tab, p)
240 if !effects.lease.Current() {
241 return
242 }
243 if effects.wasSyncing != effects.isSyncing {
244 if effects.isSyncing {
245 if renderable {
246 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
247 }
248 } else if effects.syncEnded && renderable {
249 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
250 }
251 }
252 if effects.completeSyncRead && renderable {
253 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
254 } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
255 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
256 }
257}
258
259// paint draws the composed client frame (active tab plus status bar) and
260// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
261// such as switch/create/close/rename/resize so the repaint is complete.
262func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
263 if d != nil && d.runtimeObserver != nil {
264 d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
265 }
266}
267
268// runtimeMarkBatch captures immutable runtime marks during a render transaction.
269// paint owns attachment sendMu while it captures, composes, and emits; JSONL
270// observer I/O must therefore happen only after that ownership is released.
271type runtimeMarkBatch struct {
272 observer ports.RuntimeObserver
273 marks []ports.RuntimeMark
274 attachmentEffect *attachmentEffect
275}
276
277func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
278 if d == nil {
279 return runtimeMarkBatch{}
280 }
281 return runtimeMarkBatch{observer: d.runtimeObserver}
282}
283
284func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
285 if b == nil || b.observer == nil {
286 return func(uint64, bool) {}
287 }
288 correlation := ports.NewRuntimeCorrelation()
289 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
290 return func(endBytes uint64, valid bool) {
291 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
292 }
293}
294
295func (b *runtimeMarkBatch) flush() {
296 if b == nil || b.observer == nil {
297 return
298 }
299 for _, mark := range b.marks {
300 b.observer.ObserveRuntime(mark)
301 }
302}
303
304type paintResult uint8
305
306const (
307 paintRejected paintResult = iota
308 paintEmitted
309 paintBlockedCapacity
310)
311
312func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
313 return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
314}
315
316// activation owns the drained transition gate; only its explicit ticket may
317// publish while ordinary input and render admission remain disabled.
318func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
319 // Session-owned PTY preparation remains local; attachment rendering is
320 // captured through session.captureRenderState.
321 sess := entry
322 local := sess != nil
323 marks := d.newRuntimeMarkBatch()
324 var paintEffect *attachmentEffect
325 if activation != nil {
326 paintEffect = activation
327 marks.attachmentEffect = activation
328 } else if ac.attachmentActivity() != attachmentActive {
329 return paintRejected
330 } else if lease != nil {
331 token := captureAttachmentCapability(entry, ac, ac.transport())
332 token.lease = lease
333 ticket, admitted := ac.beginAttachmentEffect(token)
334 if !admitted {
335 return paintRejected
336 }
337 paintEffect = ticket
338 marks.attachmentEffect = ticket
339 defer ticket.End()
340 }
341 var tb *tab
342 if local {
343 tb = sess.tabForAttachment(ac)
344 if tb == nil {
345 return paintRejected
346 }
347 }
348
349 ac.sendMu.Lock()
350 result, afterUnlock := d.paintLocked(entry, ac, tb, reset, lease, paintEffect, &marks)
351 ac.sendMu.Unlock()
352 afterUnlock()
353 return result
354}
355
356func noAfterUnlock() {}
357
358// paintLocked is the complete capture/compose/publish transaction. It runs with
359// ac.sendMu held and never releases it; paint is the sole owner of that lock.
360// The returned function runs after sendMu is released, with no attachment,
361// session, tab, or pane lock held.
362func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset bool, lease *attachmentLease, paintEffect *attachmentEffect, marks *runtimeMarkBatch) (paintResult, func()) {
363 sess := entry
364 local := sess != nil
365 if paintEffect != nil {
366 if !paintEffect.current() {
367 return paintRejected, noAfterUnlock
368 }
369 } else {
370 // Direct/headless rendering has no Attachment effect and retains its
371 // membership-only fixture path.
372 entry.core().mu.Lock()
373 _, owned := entry.core().attachments[ac]
374 entry.core().mu.Unlock()
375 if !owned || ac.currentAttachmentSession() != entry {
376 return paintRejected, noAfterUnlock
377 }
378 }
379 // Capacity is checked before any destructive capture. Refresh the atomic
380 // readiness snapshot while sendMu is held so direct test/setup mutations of
381 // the stream fields remain covered without making coordinator probes wait on
382 // a blocked transport send.
383 if ac.output != nil {
384 ac.output.syncCapacityLocked()
385 }
386 // The coordinator is the normal gate, but this ownership check also protects
387 // direct resize and test paint paths from consuming damage that cannot be
388 // emitted.
389 if ac.output != nil && ac.output.atCapacity() {
390 // The coordinator owns the blocked interval; this guard only protects
391 // direct test/resize paint calls from destructive capture.
392 return paintBlockedCapacity, noAfterUnlock
393 }
394 var uiFence uint64
395 if lease != nil {
396 if rc := attachmentRenderCoordinator(entry); rc != nil {
397 uiFence = rc.captureUIFence(lease)
398 }
399 }
400 // Composition owns attachment sendMu; initialize its lazy overlay state
401 // under that same ownership so concurrent fallback paints cannot observe a
402 // partially published runtime.
403 ac.initOverlays()
404 frameCapture := ac.afterFrame.beginCapture()
405 overlays := ac.overlays.SnapshotForRender()
406 repaintAttachedClients := false
407 var published bool
408 var followUp frameFollowUp
409 afterUnlock := func() {
410 // Frame follow-up (notices, repaints, send-error cleanup) runs first,
411 // then the captured sequence is emitted before any follow-up repaint can
412 // introduce another boundary.
413 if published {
414 d.settleFrame(entry, ac, marks, followUp)
415 }
416 overlays.Unlock()
417 marks.flush()
418 ac.afterFrame.runReady()
419 if repaintAttachedClients {
420 d.repaintAllAttachedClients()
421 }
422 }
423 if local {
424 d.refreshSessionFocusedTitles(sess)
425 }
426 // Contextual ranks are completely captured under paletteMu. Compose them
427 // without reading the live MRU, whose order may have changed mid-interaction.
428 statusFeedback := overlays.statusFeedback
429 if statusFeedback == "" && overlays.resizeActive {
430 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
431 }
432 bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
433 applied := ac.getAppliedTheme()
434 bars.theme = applied.Raw
435 if local {
436 attentionVisible := pulseVisible(bars.attentionFrame)
437 repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
438 }
439
440 paletteCfg := d.currentPaletteConfig()
441 floatingCfg := d.currentFloatingConfig()
442 // Title refresh may inspect process state, so it remains before the capture
443 // boundary and outside tab/pane ownership locks.
444 if local {
445 tb.mu.Lock()
446 titleIDs := ac.render.capture.titleIDs[:0]
447 if tb.tree != nil {
448 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
449 }
450 ac.render.capture.titleIDs = titleIDs
451 floating := tb.floating.pane
452 hasFloating := tb.floating.state == floatingVisible && floating != nil
453 tb.mu.Unlock()
454 for _, id := range titleIDs {
455 d.refreshPaneTitle(sess, id, tb)
456 }
457 if hasFloating {
458 d.refreshPaneDisplayTitle(sess, floating, false)
459 }
460 }
461
462 capturedOverlays := capturedOverlayRenderState{
463 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
464 paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
465 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
466 }
467 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
468 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
469 bars: bars,
470 overlays: capturedOverlays,
471 floatingCfg: floatingCfg,
472 styles: applied.Resolved.Styles,
473 styleGeneration: applied.Generation,
474 reset: reset,
475 lease: lease,
476 uiFence: uiFence,
477 })
478 endCapture(0, ok)
479 if !ok {
480 return paintRejected, afterUnlock
481 }
482 if ac.renderStages.capture != nil {
483 ac.renderStages.capture()
484 }
485 state.frameCapture = frameCapture
486 captureOverlayLayers(state, overlays, paletteCfg)
487 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
488 composed := composeFrame(*state, ac.render.cache, ac.render.spare)
489 endCompose(0, true)
490 if ac.renderStages.compose != nil {
491 ac.renderStages.compose()
492 }
493 followUp, published = d.publishFrameLocked(entry, ac, state, composed, marks)
494 return paintEmitted, afterUnlock
495}
496
497var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498
499func resolveStyles(styles []themeui.Styles) themeui.Styles {
500 if len(styles) > 0 {
501 return styles[0]
502 }
503 return fallbackChromeStyles
504}
505
506type barCache struct {
507 top []renderer.Cell
508 bottom []renderer.Cell
509}
510
511func (c *barCache) Reset() {
512 c.top = nil
513 c.bottom = nil
514}
515
516func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
517 pl.Content.X += dx
518 pl.Content.Y += dy
519 pl.TitleBar.X += dx
520 pl.TitleBar.Y += dy
521 return pl
522}
523
524func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
525 startX, startY := max(r.X, 0), max(r.Y, 0)
526 endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
527 for y := startY; y < endY; y++ {
528 sy := y - r.Y
529 if sy < 0 || sy >= src.Height {
530 continue
531 }
532 for x := startX; x < endX; x++ {
533 sx := x - r.X
534 if sx < 0 || sx >= src.Width {
535 continue
536 }
537 cell := src.At(sx, sy)
538 if dim {
539 cell.Style = dimmer.Dim(cell.Style)
540 }
541 dst.Set(x, y, cell)
542 }
543 }
544}
545
546// drawDividers paints each precomputed divider gap onto frame, offsetting by
547// dy (composeFrame's content area starts one row below the top status bar).
548// The dividers themselves come from layout.SolveWithDividers, computed once
549// per capture alongside pane placements, rather than recomputed here.
550func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
551 for _, d := range dividers {
552 glyph := rune('│')
553 if d.Dir == layout.Vertical {
554 glyph = '─'
555 }
556 y0 := max(d.Rect.Y+dy, 0)
557 y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
558 x0 := max(d.Rect.X, 0)
559 x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
560 for y := y0; y < y1; y++ {
561 for x := x0; x < x1; x++ {
562 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
563 }
564 }
565 }
566}
567
568func (c *barCache) capture(top, bottom []renderer.Cell) {
569 c.top = cloneCells(c.top, top)
570 c.bottom = cloneCells(c.bottom, bottom)
571}
572
573func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
574 if cap(dst) < len(src) {
575 dst = make([]renderer.Cell, len(src))
576 } else {
577 dst = dst[:len(src)]
578 }
579 copy(dst, src)
580 return dst
581}
582
583func sameCells(a, b []renderer.Cell) bool {
584 if len(a) != len(b) {
585 return false
586 }
587 for i := range a {
588 if !a[i].Equal(b[i]) {
589 return false
590 }
591 }
592 return true
593}
594
595func offsetDamage(in []renderer.Damage) []renderer.Damage {
596 return translateDamage(in, 0, 1)
597}
598
599func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
600 out := make([]renderer.Damage, len(in))
601 for i, d := range in {
602 out[i] = d
603 if d.Kind != renderer.DamageFullRedraw {
604 out[i].X += dx
605 out[i].Y += dy
606 }
607 }
608 return out
609}
610
611func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
612 if d.Kind == renderer.DamageFullRedraw {
613 return []renderer.Damage{d}
614 }
615 if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
616 return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
617 }
618 d.X += content.X
619 d.Y += content.Y
620 return []renderer.Damage{d}
621}