// Package daemon holds vev's server-side session multiplexer use case. package daemon import ( "bytes" "errors" "strconv" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/usecase/command" "github.com/bnema/vev/internal/usecase/keys" "github.com/bnema/vev/internal/usecase/keys/kittykey" "github.com/bnema/vev/internal/usecase/layout" "github.com/bnema/vev/internal/usecase/mouse" ) // handleSequencedInput applies input and schedules its echo acknowledgement. // Echo is not acknowledged immediately: the PTY needs time to echo the input // into a rendered screen state (see echoAckDelay). func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) { d.handleInput(sess, ac, data) d.noteInputApplied(ac, seq) } func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, seq uint64, data []byte) { if !effect.current() { return } d.handleInputForAttachment(effect, data) d.noteInputApplied(effect.ac, seq) } func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { ac.initOverlays() if d.pickerSwallowsInput(ac, nil) { // The client-owned picker owns the input: raw keys and mouse events // must not reach the session while it is open. Only its typed // selection and close messages act, on the control path. return } ac.mouseScan.Scan(data, func(ev mouse.Event) { d.handleMouse(ac, ev) }, func(b []byte) { if ac.overlays.HandleInput(d, ac.legacyKeys(b)) { return } ac.keys.Route(b) }, ) } func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) { ac := effect.ac ac.initOverlays() if d.pickerSwallowsInput(ac, effect) { // Same rule as handleInput: the client-owned picker consumes user // input, mouse included, until it closes or is released unacquired. return } ac.mouseScan.Scan(data, func(ev mouse.Event) { if effect.current() { d.handleMouse(ac, ev) } }, func(b []byte) { if !effect.current() { return } // Overlays parse legacy key bytes; only the router and panes see // kitty keyboard sequences. if ac.overlays.HandleInput(d, ac.legacyKeys(b), effect) { return } if !effect.current() { return } ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, effect: effect}) }, ) } func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { d.handleMouseMutation(ac, ev) } func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) { frameEvent := ev ac.initOverlays() rt := ac.overlays if rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() { return } sess := ac.currentSession() if sess == nil { invalidateRejectedLeftPointer(rt, ev) return } tb := sess.tabForAttachment(ac) if tb == nil { invalidateRejectedLeftPointer(rt, ev) return } if d.handleCopyMouse(sess, ac, tb, ev) { return } // A fresh drag is pinned to its press geometry. Handle later events before // normal terminal routing so crossing a split cannot retarget its document. if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) { return } // Copy-mode publication deliberately revalidates outside the dispatch lock: // a concurrent release or pane close must be able to invalidate its candidate. var routed mouseRoute _ = sess.runMutation(func() error { routed = d.routeMouseMutation(sess, ac, ev) return nil }) if routed.handled { return } if routed.p == nil { invalidateRejectedLeftPointer(rt, ev) return } if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) { return } d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused) } type mouseRoute struct { tb *tab p *pane event mouse.Event translated bool hoveredFocused bool handled bool } func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute { route := mouseRoute{event: ev, hoveredFocused: true} tb := sess.tabForAttachment(ac) if tb == nil { route.handled = true return route } route.tb = tb contentRow := ev.Row - clientTopBarRows tb.mu.Lock() floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) if floatingVisible { if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { tb.mu.Unlock() route.handled = true return route } tb.mu.Unlock() route.p = floating route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) route.translated = true return route } pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) multi := len(tb.panes) > 1 focusedID := mouseFocusedPaneIDLocked(ac, tb) if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { if !isMouseFocusPress(ev) { tb.mu.Unlock() route.handled = true return route } tb.mu.Unlock() _, focused := d.focusMousePane(sess, ac, tb, pl.ID) if focused == nil { route.handled = true return route } invalidateRejectedLeftPointer(ac.overlays, ev) if pl.ID != focusedID { d.exitCopyMode(ac) d.refreshPaneTitleOnFocus(sess, pl.ID) } d.invalidateRender(sess, ac, true, "input.go") route.handled = true return route } if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { oldFocus := focusedID shouldFocus := isMouseFocusPress(ev) p := tb.panes[pl.ID] hoveredFocused := pl.ID == oldFocus tb.mu.Unlock() if shouldFocus { _, p = d.focusMousePane(sess, ac, tb, pl.ID) } if p == nil { route.handled = true return route } if shouldFocus && pl.ID != oldFocus { d.exitCopyMode(ac) d.refreshPaneTitleOnFocus(sess, pl.ID) d.invalidateRender(sess, ac, true, "input.go") } route.p = p route.hoveredFocused = hoveredFocused if multi { route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) route.translated = true } return route } if multi { tb.mu.Unlock() route.handled = true return route } tb.mu.Unlock() _, route.p = sess.paneForAttachment(ac) return route } func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID { if ac != nil && tb != nil { view := ac.viewSnapshot() if domain.TabStableID(tb.stableID) == view.tabID { for _, p := range tb.panes { if p != nil && domain.PaneStableID(p.stableID) == view.paneID { return p.id } } } } if tb == nil || tb.tree == nil { return "" } return tb.tree.Focus } // focusMousePane runs inside the caller's session mutation boundary. func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) { var changed bool var focused *pane tb.mu.Lock() if tb.tree != nil { focused = tb.panes[id] if focused != nil { changed = focusPlacementLocked(tb, id) } } tb.mu.Unlock() if focused == nil { return false, nil } sess.mu.Lock() sess.setAttachmentPaneLocked(ac, tb, focused) sess.mu.Unlock() if changed { sess.geometry.applyTabLayout(d, sess, tb) } return changed, focused } func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { if p == nil { return } p.mu.Lock() childRows := p.screen.Rows() mouseMode, mouseSGR := p.screen.MouseMode() altScreen := p.screen.AltScreenActive() p.mu.Unlock() if mouseMode != 0 { if !mouseSGR || ev.Row == 0 || ev.Row > childRows { return } _ = sess.runMutation(func() error { if translated { d.writeToPane(sess, p, ev.Raw) } else { d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) } return nil }) return } switch ev.Button { case mouse.WheelUp: if altScreen { _ = sess.runMutation(func() error { d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) return nil }) return } if !hoveredFocused { return } d.enterCopyMode(sess, ac) d.smoothCopyWheel(sess, ac, -3) case mouse.WheelDown: if altScreen { _ = sess.runMutation(func() error { d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B")) return nil }) } } } func isMouseFocusPress(ev mouse.Event) bool { return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right) } func clampInt(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { if p == nil || p.pty == nil { return } if _, err := p.pty.Write(data); err != nil { name := "" if sess != nil { sess.mu.Lock() name = sess.name sess.mu.Unlock() } d.log.Error("pty write failed", "err", err, "session", name) d.notify(sess, domain.NoticeError, domain.NoticeInputDropped, "input not delivered to pane", err) } } func sgrRowOffset(raw []byte, delta int) []byte { return sgrOffset(raw, 0, delta) } func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { if len(raw) < len("\x1b[<0;1;1M") { return raw } end := len(raw) - 1 if raw[0] != '\x1b' || raw[1] != '[' || raw[2] != '<' || (raw[end] != 'M' && raw[end] != 'm') { return raw } parts := bytes.Split(raw[3:end], []byte(";")) if len(parts) != 3 { return raw } cx, err := strconv.Atoi(string(parts[1])) if err != nil { return raw } cy, err := strconv.Atoi(string(parts[2])) if err != nil { return raw } cx += colDelta cy += rowDelta if cx < 1 || cy < 1 { return raw } out := make([]byte, 0, len(raw)+4) out = append(out, raw[:3]...) out = append(out, parts[0]...) out = append(out, ';') out = strconv.AppendInt(out, int64(cx), 10) out = append(out, ';') out = strconv.AppendInt(out, int64(cy), 10) out = append(out, raw[end]) return out } type daemonKeyHandler struct { d *Daemon ac *attachedClient actions daemonActionRunner effect *attachmentEffect } // acquireAttachmentEffect preserves a synchronous frame's existing admission and // gives delayed router callbacks a fresh ticket for the exact captured attachment. // Direct/headless callers without an attachment token retain their existing behavior. func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect, bool) { if h.effect != nil && h.effect.ac != nil { if h.effect.current() { return h.effect.sess, h.effect, false } effect, admitted := h.effect.ac.beginAttachmentEffect(h.effect.capability()) if h.d.afterDelayedKeyEffectAttempt != nil { h.d.afterDelayedKeyEffectAttempt(admitted) } if !admitted { return nil, nil, false } effect.uiActionID = h.effect.uiActionID if h.d.afterAttachmentEffectAdmitted != nil { h.d.afterAttachmentEffectAdmitted(effect.capability()) } if effect.sess == nil { effect.End() return nil, nil, false } return effect.sess, effect, true } sess := h.ac.currentSession() if sess == nil { return nil, nil, false } return sess, nil, false } func (h daemonKeyHandler) Forward(data []byte) { sess, effect, owned := h.acquireAttachmentEffect() if sess == nil { return } if owned { defer effect.End() } _ = sess.runMutation(func() error { tb := sess.tabForAttachment(h.ac) if tb == nil { return nil } tb.mu.Lock() p := tb.terminalTargetLocked() tb.mu.Unlock() if h.ac.keys.KittyKeyboard() { data = paneKeyInput(p, data) } h.d.writeToPane(sess, p, data) return nil }) } // paneKeyInput re-encodes kitty keyboard protocol key events for the pane's // program: legacy bytes unless it requested the protocol itself. func paneKeyInput(p *pane, data []byte) []byte { if p == nil { return data } p.mu.Lock() if p.screen == nil { p.mu.Unlock() return data } flags := p.screen.KittyKeyboardFlags() p.mu.Unlock() return kittykey.Translate(data, flags) } // legacyKeys returns input as legacy key bytes for daemon overlays. Only an // attachment that enabled the kitty keyboard protocol needs translating. func (ac *attachedClient) legacyKeys(data []byte) []byte { if !ac.keys.KittyKeyboard() { return data } return kittykey.Translate(data, 0) } func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { sess, effect, owned := h.acquireAttachmentEffect() if sess == nil { return } if owned { defer effect.End() } runAction := func(request daemonActionRequest) { request.effect = effect if _, err := h.d.dispatchAction(daemonActionDispatch{ runner: h.actions, request: request, serialize: sess, resolveFrom: sess, attachment: h.ac, producer: "input.go", }); err != nil { var userErr *domain.UserError if !errors.As(err, &userErr) { err = resizeUserError(err) } h.d.reportError(sess, err) } } switch action { case keys.ActionOpenPalette: interaction := h.d.enterPalette(sess, h.ac) h.d.sendPaletteInventoryDemand(h.ac, effect, true, interaction) case keys.ActionJumpAttention: if effect == nil { if err := h.d.jumpAttention(sess, h.ac); err != nil { h.d.reportError(sess, err) } return } effect.bindActionEnd(h.d, "jump-attention") if err := h.d.jumpAttentionForAttachment(sess, h.ac, effect); err != nil { h.d.reportError(sess, err) } case keys.ActionToggleFloatingPane: if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil { h.d.log.Warn("toggle floating pane failed", "err", err) h.d.reportError(sess, err) } case keys.ActionFocusPaneLeft: h.focus(sess, layout.Left, effect) case keys.ActionFocusPaneRight: h.focus(sess, layout.Right, effect) case keys.ActionFocusPaneUp: h.focus(sess, layout.Up, effect) case keys.ActionFocusPaneDown: h.focus(sess, layout.Down, effect) case keys.ActionGrowPaneWidth: runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) case keys.ActionShrinkPaneWidth: runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) case keys.ActionGrowPaneHeight: runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) case keys.ActionShrinkPaneHeight: runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) case keys.ActionEqualizePanes: runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) case keys.ActionConsumeOrExpelPaneLeft: runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left}) case keys.ActionConsumeOrExpelPaneRight: runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right}) case keys.ActionSwitchRecent1, keys.ActionSwitchRecent2, keys.ActionSwitchRecent3, keys.ActionSwitchRecent4, keys.ActionSwitchRecent5, keys.ActionSwitchRecent6, keys.ActionSwitchRecent7, keys.ActionSwitchRecent8, keys.ActionSwitchRecent9: rank := int(action-keys.ActionSwitchRecent1) + 1 exec := paletteExec{d: h.d, sess: sess, attachment: sess, ac: h.ac, routeSnapshot: h.ac.routeSnapshotCopy(), effect: effect} if err := exec.JumpRecentSession(rank); err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, command.ErrInvalidArguments) { h.d.reportError(sess, err) } case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: idx := int(action - keys.ActionSwitchTab1) _ = sess.runMutation(func() error { if sess.switchAttachmentTabForDispatch(h.ac, idx) { h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil) h.d.invalidateRender(sess, h.ac, true, "input.go") } return nil }) } } func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffect) { err := h.d.focusDir(sess, h.ac, dir, effect) if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) { h.d.reportError(sess, err) } }