package daemon import ( "errors" "sort" "sync" "time" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/domain/layout" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/command" "github.com/bnema/vev/internal/usecase/palette" "github.com/bnema/vev/internal/usecase/picker" themeui "github.com/bnema/vev/internal/usecase/theme" "github.com/bnema/vev/internal/usecase/ui" ) var ( // Floating palettes use a compact rail; below ui.ResponsiveDrawerBreakpoint // the presentation becomes a full-width drawer regardless of this width. paletteModal = ui.Modal{FixedWidth: 64, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottomRight, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}} errCreateDestinationUnavailable = errors.New("that destination is no longer available") ) func paletteModalFor(cfg domain.PaletteConfig) ui.Modal { modal := paletteModal if cfg.AnchorSet { modal.Anchor = cfg.Anchor } return modal } func (d *Daemon) enterPalette(sess *session, ac *attachedClient) uint64 { // Capture the client-owned route snapshot before taking paletteMu. The // snapshot is immutable for this palette interaction and the daemon never // consults its own session history for recent-route commands. routeSnapshot := ac.routeSnapshotCopy() commands := d.paletteCommands() results := d.paletteResults(sess, commands, routeSnapshot) model := palette.New(results) ac.overlays.paletteMu.Lock() ac.overlays.paletteGeneration++ ac.overlays.palette = model ac.overlays.paletteRouteSnapshot = routeSnapshot ac.overlays.paletteHints = palette.ContextualHints{} ac.overlays.palettePreview = "" ac.overlays.paletteFeedback = "" ac.overlays.palettePending = nil interaction := openPaletteInventory(ac.overlays) ac.overlays.paletteMu.Unlock() d.invalidateRender(sess, ac, true, "palette.go") return interaction } type paletteSessionIdentity struct { kind protocol.RouteKind endpoint string lifecycle domain.SessionLifecycleID } func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity { return paletteSessionIdentity{kind: protocol.RouteKindLocal, endpoint: "local", lifecycle: lifecycle} } func paletteRemoteDisplayOrigin(hostLabel string) string { origin := domain.RemoteDisplayOrigin(hostLabel) if origin == "" { return "remote" } return origin } func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) string { active, ok := activeRouteEntryForLifecycle(snapshot, lifecycle) if !ok || active.Kind != protocol.RouteKindRemote { return "" } return paletteRemoteDisplayOrigin(active.HostLabel) } func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) { if ref.IsZero() { return protocol.RecentRouteEntry{}, false } if snapshot.Active == ref { entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation} if entryRef == ref { return snapshot.ActiveEntry, true } } for _, entry := range snapshot.Entries { if entry.Key == ref.Key && entry.Generation == ref.Generation { return entry, true } } return protocol.RecentRouteEntry{}, false } // createSessionDestinationResults lists where CNS may create a session: the // serving daemon itself, and every other daemon the client published as a // route host. Only the serving daemon creates in place; every // other destination is created by the client through its broker. func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID) []palette.Result { results := make([]palette.Result, 0, len(snapshot.Hosts)+1) if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { results = append(results, palette.NewCreateSessionDestination( palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, )) } if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { results = append(results, palette.NewCreateSessionDestination( palette.CreateSessionOnServingDaemon, paletteRemoteDisplayOrigin(active.HostLabel), "", protocol.RouteRef{}, 0, )) } for _, host := range snapshot.Hosts { ref := protocol.RouteRef{Key: host.Key, Generation: host.Generation} if host.Kind == protocol.RouteKindLocal { results = append(results, palette.NewCreateSessionDestination( palette.CreateSessionOnLocalRoute, "", "", ref, snapshot.Generation, )) continue } results = append(results, palette.NewCreateSessionDestination( palette.CreateSessionOnRemoteHost, paletteRemoteDisplayOrigin(host.Label), "", ref, snapshot.Generation, )) } return results } // routeHostForRef finds one creation host of the snapshot. func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) { for _, host := range snapshot.Hosts { if host.Key == ref.Key && host.Generation == ref.Generation && !ref.IsZero() { return host, true } } return protocol.RouteHost{}, false } // routeCreationKind reports the kind of daemon one creation reference names: // a route entry (or the active route) or a route host. func routeCreationKind(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteKind, bool) { if entry, ok := routeEntryForRef(snapshot, ref); ok { return entry.Kind, true } if host, ok := routeHostForRef(snapshot, ref); ok { return host.Kind, true } return 0, false } func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { if entry.Kind == protocol.RouteKindLocal { return true } return entry.Kind == protocol.RouteKindRemote && daemonDisplayOrigin != "" && paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin } // paletteResults projects the daemon's own inventory plus the client's route // snapshot for the palette. It keeps quick-switch exclusions, compact labels, // commands, and history behavior; other daemons' sessions and hosts come only // from the client routes, and committing one navigates through // the client. func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result { inv := d.captureSessionInventory(viewOptions{}, false) stopped := inv.resumableStopped() represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries)) for _, entry := range inv.stopped { represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{} } var currentLifecycle domain.SessionLifecycleID if current != nil { current.mu.Lock() currentLifecycle = current.incarnation current.mu.Unlock() } daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != "" for _, entry := range routeSnapshot.Entries { hybrid = hybrid || entry.Kind == protocol.RouteKindRemote } if daemonDisplayOrigin == "" && hybrid { daemonDisplayOrigin = "local" } destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle) results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+len(routeSnapshot.Entries)) for _, cmd := range commands { results = append(results, palette.NewCommandResult(cmd)) } results = append(results, destinations...) active := make([]palette.Result, 0, len(inv.live)) for _, candidate := range inv.live { snap := candidate.view if snap.name == "" || snap.ephemeral { continue } represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{} if candidate.sess == current { continue } target := protocol.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} active = append(active, palette.NewActiveSessionResultWithDisplayOrigin(target, time.Unix(0, snap.createdAt), daemonDisplayOrigin)) } sort.Slice(active, func(i, j int) bool { left, _ := active[i].SessionName() right, _ := active[j].SessionName() return left < right }) sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) results = append(results, active...) for _, candidate := range stopped { target := protocol.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin)) represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{} } for _, entry := range routeSnapshot.Entries { if entry.Name == "" { continue } origin := "" if hybrid { origin = "local" } if entry.Kind == protocol.RouteKindRemote { origin = paletteRemoteDisplayOrigin(entry.HostLabel) } label := palette.SessionDisplay(entry.Name, origin) identity := localPaletteSessionIdentity(entry.Target.LifecycleID) if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { continue } results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{ SnapshotGeneration: routeSnapshot.Generation, Key: entry.Key, Generation: entry.Generation, })) if entry.Kind == protocol.RouteKindLocal { represented[identity] = struct{}{} } } return results } func (d *Daemon) refreshPalette(ac *attachedClient) { if d == nil || ac == nil || ac.overlays == nil { return } sess := ac.currentAttachmentSession() if sess == nil { return } rt := ac.overlays rt.paletteMu.Lock() model := rt.palette generation := rt.paletteGeneration routeSnapshot := rt.paletteRouteSnapshot routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...) rt.paletteMu.Unlock() if model == nil { return } results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot) results = appendImportedResults(results, ac) rt.paletteMu.Lock() defer rt.paletteMu.Unlock() if rt.paletteGeneration != generation || rt.palette != model { return } model.ReplaceResults(results) } func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints { snapshot = historyRouteSnapshot(snapshot) formatted := formatRecentRouteSnapshot(snapshot) names := make([]string, len(formatted)) for i, entry := range formatted { names[i] = entry.name } hints := palette.BuildRecentSessionHints(names, args) for i, entry := range snapshot.Entries { if i >= len(hints.Recent) { break } hints.Recent[i].SnapshotGeneration = snapshot.Generation hints.Recent[i].Key = entry.Key hints.Recent[i].Generation = entry.Generation } return hints } // paletteHistory is the daemon-wide most-recently-used command order. mu is a // leaf lock: nothing is acquired while holding it. The zero value is usable. type paletteHistory struct { mu sync.Mutex recent []string } // snapshot returns a copy of the recent command codes, newest first. func (h *paletteHistory) snapshot() []string { h.mu.Lock() defer h.mu.Unlock() return append([]string(nil), h.recent...) } // record moves code to the front of the recent list. func (h *paletteHistory) record(code string) { h.mu.Lock() defer h.mu.Unlock() recent := make([]string, 0, len(h.recent)+1) recent = append(recent, code) for _, existing := range h.recent { if existing != code { recent = append(recent, existing) } } h.recent = recent } func (d *Daemon) paletteCommands() []command.Command { commands := command.PaletteRegistry() recent := d.paletteHistory.snapshot() overrides := d.codeOverrideSnapshot() byCode := make(map[string]command.Command, len(commands)) for _, cmd := range commands { cmd = commandWithOverrides(cmd, overrides) byCode[cmd.Code] = cmd } out := make([]command.Command, 0, len(commands)) used := make(map[string]bool, len(recent)) for _, code := range recent { cmd, ok := byCode[code] if !ok || used[code] { continue } out = append(out, cmd) used[code] = true } for _, cmd := range commands { cmd = commandWithOverrides(cmd, overrides) if !used[cmd.Code] { out = append(out, cmd) } } return out } // paletteDecisionKind names what handlePaletteInput must do once the palette // lock is released. type paletteDecisionKind int const ( paletteDecisionNone paletteDecisionKind = iota paletteDecisionRedraw paletteDecisionCancel paletteDecisionCreateDestination paletteDecisionImportedSession paletteDecisionRouteNavigation paletteDecisionSessionTarget paletteDecisionCommand ) // paletteDecision is the immutable outcome of routing one input chunk through // the palette under paletteMu. Executing it never touches palette state without // retaking the lock. type paletteDecision struct { kind paletteDecisionKind generation uint64 rawQuery string cmd command.Command args []string routeSnapshot protocol.RecentRouteSnapshot sessionTarget palette.Result importedSourceKey string importedEntryKey string routeTarget protocol.RouteNavigationAction createDestination palette.Result cancelInventoryInteraction uint64 hasCancelInventory bool } // decidePaletteInput takes paletteMu, applies data to the attachment's palette, // and returns the action to run after unlocking. func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision { // decision holds the executed selection; execute reports one was made. var decision paletteDecision changed, cancel, execute, chooseDestination := false, false, false, false ac.overlays.paletteMu.Lock() if ac.overlays.palette == nil { ac.overlays.palettePending = nil ac.overlays.paletteMu.Unlock() return paletteDecision{} } routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ rune: func(r rune) { if !execute { ac.overlays.palette.Insert(r) changed = true } }, backspace: func() { if !execute { ac.overlays.palette.Backspace() changed = true } }, tab: func() { if !execute { changed = ac.overlays.palette.CompleteSelected() || changed } }, up: func() { if !execute { ac.overlays.palette.Up() changed = true } }, down: func() { if !execute { ac.overlays.palette.Down() changed = true } }, cancel: func() { if !execute { cancel = true } }, enter: func() { if execute { return } selected, ok := ac.overlays.palette.Selected() if !ok { changed = true chooseDestination = true return } next := paletteDecision{rawQuery: ac.overlays.palette.Query(), generation: ac.overlays.paletteGeneration} if cmd, ok := selected.Command(); ok { next.kind, next.cmd = paletteDecisionCommand, cmd if cmd.Arguments != command.ArgumentsNone { action, valid := palette.ParseAction([]palette.Result{selected}, next.rawQuery) if valid { next.args = action.Args } else if cmd.Arguments == command.ArgumentsRequired { changed = true return } } if cmd.ContextHint == command.ContextHintRecentSessions { next.routeSnapshot = ac.overlays.paletteRouteSnapshot next.routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), next.routeSnapshot.Entries...) } } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok { next.kind, next.importedSourceKey, next.importedEntryKey = paletteDecisionImportedSession, sourceKey, entryKey } else if action, ok := selected.RouteNavigationAction(); ok { next.kind, next.routeTarget = paletteDecisionRouteNavigation, action } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok { next.kind, next.createDestination = paletteDecisionCreateDestination, selected } else if _, ok := selected.SessionTarget(); ok { next.kind, next.sessionTarget = paletteDecisionSessionTarget, selected } else { changed = true return } decision = next execute = true }, }) if changed { ac.overlays.paletteFeedback = "" if chooseDestination { ac.overlays.paletteFeedback = "Choose a destination" } ac.overlays.palettePreview = "" active, ok := ac.overlays.palette.ArgumentCommand() if ok { ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query()) } if ok && active.Slug == "jump-recent-session" { args := paletteArgs(ac.overlays.palette.Query(), active) if ac.overlays.paletteRouteSnapshot.Generation != 0 { ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args) } else { ac.overlays.paletteHints = palette.ContextualHints{} } } else { ac.overlays.paletteHints = palette.ContextualHints{} } } if cancel { cancelled := paletteDecision{kind: paletteDecisionCancel} cancelled.cancelInventoryInteraction, cancelled.hasCancelInventory = takePaletteInventoryClose(ac.overlays) ac.clearPaletteLocked() ac.overlays.paletteMu.Unlock() return cancelled } ac.overlays.paletteMu.Unlock() switch { case execute: return decision case changed: return paletteDecision{kind: paletteDecisionRedraw} default: return paletteDecision{} } } func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) { entry := ac.currentAttachmentSession() if entry == nil { return } var effect *attachmentEffect if len(effects) != 0 { effect = effects[0] } d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data)) } // executePaletteDecision runs the outcome of decidePaletteInput with no palette // lock held. func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) { generation, rawQuery := decision.generation, decision.rawQuery switch decision.kind { case paletteDecisionNone: case paletteDecisionCancel: if decision.hasCancelInventory { d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction) } d.invalidateRender(entry, ac, true, "palette.go") case paletteDecisionRedraw: d.invalidateRender(entry, ac, true, "palette.go") case paletteDecisionCreateDestination: d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination) case paletteDecisionImportedSession: d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey) case paletteDecisionRouteNavigation: d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget) case paletteDecisionSessionTarget: d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget) case paletteDecisionCommand: d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot) } } func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) { name, _, _, _, _, _ := createDestination.CreateSessionDestination() exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect} if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil { ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error()) d.invalidateRender(entry, ac, true, "palette.go") return } if !d.closeExecutedPalette(ac, effect, generation, rawQuery) { return } if name == "" { d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error { return exec.createSessionOnDestination(promptEffect, createDestination, name) }) d.invalidateRender(entry, ac, true, "palette.go") return } err := exec.createSessionOnValidatedDestination(effect, createDestination, name) if err != nil && !errors.Is(err, errAttachmentTransition) { d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err)) return } if current := ac.currentAttachmentSession(); current != nil { d.invalidateRender(current, ac, true, "palette.go") } } func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) { if effect == nil { ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } if importedSourceKey != protocol.NavigationInventoryLocalSourceKey { // Remote vision is read-only: imported remote routes surface // availability but never attach. ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached") d.invalidateRender(entry, ac, true, "palette.go") return } ac.overlays.paletteMu.Lock() open := ac.overlays.paletteInventoryOpen selection := protocol.NavigationInventorySelection{ CauseActionID: effect.uiActionID, InteractionGeneration: ac.overlays.paletteInventoryInteraction, PublicationGeneration: ac.overlays.paletteInventoryPublication, SourceKey: importedSourceKey, EntryKey: importedEntryKey, } if open { freezePaletteInventorySelection(ac.overlays, selection) } ac.overlays.paletteMu.Unlock() if !open || protocol.ValidateNavigationInventorySelection(selection) != nil { ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } // Selection crosses before the close demand on the guarded serving // connection; closing after selection must not cancel the client's // pending navigation operation. if err := effect.sendControl(selection); err != nil { ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } d.closeExecutedPalette(ac, effect, generation, rawQuery) } func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) { exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect} if err := exec.NavigateRecentRoute(routeTarget); err != nil { if errors.Is(err, errAttachmentTransition) { return } ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } if d.closeExecutedPalette(ac, effect, generation, rawQuery) { d.invalidateRender(entry, ac, true, "palette.go") } } func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) { createdAt, _ := sessionTarget.SessionCreatedAt() expectedCreatedAt := createdAt.UnixNano() name, _ := sessionTarget.SessionName() exactTarget, _ := sessionTarget.SessionTarget() target := picker.Target{ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession, } var err error if effect != nil { err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session") } else { err = d.switchToTarget(entry, ac, target) } if errors.Is(err, errAttachmentTransition) { return } if err != nil { ac.paletteFailure(generation, rawQuery, "requested session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } if d.closeExecutedPalette(ac, effect, generation, rawQuery) { if current := ac.currentAttachmentSession(); current != nil { d.invalidateRender(current, ac, true, "palette.go") } } } func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) { if cmd.Slug == "jump-recent-session" { rank, err := command.ParsePositiveDecimal(args) if err != nil { ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal") d.invalidateRender(entry, ac, true, "palette.go") return } exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect} if err := exec.JumpRecentSession(rank); err != nil { if errors.Is(err, errAttachmentTransition) { return } ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") d.invalidateRender(entry, ac, true, "palette.go") return } if d.closeExecutedPalette(ac, effect, generation, rawQuery) { d.paletteHistory.record(cmd.Code) if current := ac.currentAttachmentSession(); current != nil { d.invalidateRender(current, ac, true, "palette.go") } } return } attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { return } entry.dispatchMu.Lock() err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args) entry.dispatchMu.Unlock() if attachmentHandoff { if current := ac.currentSession(); current != nil { currentToken := current.captureAttachmentCapability(ac, ac.transport()) fresh, admitted := ac.beginAttachmentEffect(currentToken) if d.closeExecutedPalette(ac, effect, generation, rawQuery) { d.invalidateRender(current, ac, true, "palette.go") } if admitted { fresh.End() } } else { d.closeExecutedPalette(ac, effect, generation, rawQuery) } } if errors.Is(err, errAttachmentTransition) { return } if err != nil { d.log.Error("palette command failed", "err", err, "command", cmd.Code) d.reportError(entry, paletteCommandNoticeError(cmd, err)) } else { d.paletteHistory.record(cmd.Code) } } func paletteCommandNoticeError(cmd command.Command, err error) error { if cmd.Scope == command.CommandScopeCrossSession { return movePickerUserError(err) } return err } func paletteArgs(query string, cmd command.Command) []string { action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) if !ok { return nil } return action.Args } // clearPaletteLocked requires ac.overlays.paletteMu. func (ac *attachedClient) clearPaletteLocked() { ac.overlays.paletteGeneration++ ac.overlays.palette = nil ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{} ac.overlays.paletteHints = palette.ContextualHints{} ac.overlays.palettePreview = "" ac.overlays.paletteFeedback = "" ac.overlays.palettePending = nil closePaletteInventory(ac.overlays) } func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { ac.overlays.paletteMu.Lock() defer ac.overlays.paletteMu.Unlock() if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { return } ac.overlays.paletteFeedback = feedback } func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool { ac.overlays.paletteMu.Lock() if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { ac.overlays.paletteMu.Unlock() return false } // Every successful close stops relay polling: take the close demand // before clearing so no execute path leaks a 1/s poll loop. closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays) ac.clearPaletteLocked() ac.overlays.paletteMu.Unlock() if hasClose { d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction) } return true } func (d *Daemon) closePalette(ac *attachedClient) { d.closePaletteIfCurrent(ac, 0) } func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool { if d == nil || ac == nil || ac.overlays == nil { return false } ac.overlays.paletteMu.Lock() if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation { ac.overlays.paletteMu.Unlock() return false } ac.clearPaletteLocked() ac.overlays.paletteMu.Unlock() return true } type paletteExec struct { d *Daemon sess *session attachment *session ac *attachedClient routeSnapshot protocol.RecentRouteSnapshot actions daemonActionRunner effect *attachmentEffect redrawClosedPalette bool } func (e paletteExec) runAction(request daemonActionRequest) error { request.effect = e.effect changed, err := e.d.dispatchAction(daemonActionDispatch{ runner: e.actions, request: request, resolveFrom: e.sess, attachment: e.ac, producer: "palette.go", }) if err == nil && !changed && e.redrawClosedPalette && e.d != nil { // The palette closed before a no-op action; repaint what it covered. e.d.invalidateRender(e.sess, e.ac, true, "palette.go") } return err } func (e paletteExec) CreateTab() error { return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()}) } func (e paletteExec) CreateSession() error { entry := e.attachment if entry == nil { entry = e.sess } e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error { if effect == nil || effect.ac == nil { if e.sess == nil { return errAttachmentTransition } return e.d.createSessionAndSwitch(e.sess, e.ac, name) } return e.d.createSessionAndSwitchForAttachment(effect, name) }) return nil } func (e paletteExec) CreateSessionNamed(name string) error { if e.effect == nil { return e.d.createSessionAndSwitch(e.sess, e.ac, name) } return e.d.createSessionAndSwitchForAttachment(e.effect, name) } func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error { _, kind, _, _, route, ok := result.CreateSessionDestination() if !ok { return errCreateDestinationUnavailable } switch kind { case palette.CreateSessionOnServingDaemon: return nil case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost: // Another daemon is a client route: its reference must still name a // daemon of the kind shown in the latest snapshot. if e.ac == nil { return errCreateDestinationUnavailable } snapshotGeneration, ok := result.CreateSessionSnapshotGeneration() if !ok || snapshotGeneration == 0 { return errCreateDestinationUnavailable } want := protocol.RouteKindLocal if kind == palette.CreateSessionOnRemoteHost { want = protocol.RouteKindRemote } snapshot := e.ac.routeSnapshotCopy() got, valid := routeCreationKind(snapshot, route) if !valid || snapshot.Generation != snapshotGeneration || got != want { return errCreateDestinationUnavailable } if effect == nil && snapshot.Active != route { return errCreateDestinationUnavailable } return nil default: return errCreateDestinationUnavailable } } func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error { if err := e.validateCreateSessionDestination(effect, result); err != nil { return err } return e.createSessionOnValidatedDestination(effect, result, name) } func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error { if err := domain.ValidateSessionName(name); err != nil { return err } _, kind, _, _, route, _ := result.CreateSessionDestination() if kind == palette.CreateSessionOnServingDaemon || (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) { if effect == nil { return e.d.createSessionAndSwitch(e.sess, e.ac, name) } return e.d.createSessionAndSwitchForAttachment(effect, name) } if effect == nil { return errAttachmentTransition } requestID := e.d.creationRequestSeq.Add(1) if requestID == 0 { requestID = e.d.creationRequestSeq.Add(1) } switch kind { case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost: // The client creates it through its broker and swaps to it. snapshotGeneration, _ := result.CreateSessionSnapshotGeneration() action := protocol.RouteCreateSessionAction{ RequestID: requestID, SnapshotGeneration: snapshotGeneration, Key: route.Key, Generation: route.Generation, SessionName: name, } if err := effect.sendControl(action); err != nil { return err } return nil default: return errAttachmentTransition } } func (e paletteExec) CreateEphemeralSession() error { if e.effect == nil { return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac) } return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect) } func (e paletteExec) CloseTab() error { return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) } func (e paletteExec) split(direction layout.Direction) error { return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) } func (e paletteExec) SplitRight() error { return e.split(layout.Right) } func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } func (e paletteExec) SplitUp() error { return e.split(layout.Up) } func (e paletteExec) SplitDown() error { return e.split(layout.Down) } func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error { return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) } func (e paletteExec) ConsumeOrExpelPaneLeft() error { return e.consumeOrExpelPane(layout.Left) } func (e paletteExec) ConsumeOrExpelPaneRight() error { return e.consumeOrExpelPane(layout.Right) } func (e paletteExec) StackPane() error { return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) } func (e paletteExec) ToggleStack() error { return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) } func (e paletteExec) ClosePane() error { return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) } func (e paletteExec) OpenMovePanePicker() error { target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) if target.tab == nil || target.pane == nil { return errMovePaneInvalid } return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{ Session: sessionMoveLocator(e.sess), TabID: domain.TabStableID(target.tab.stableID), PaneID: domain.PaneStableID(target.pane.stableID), Attachment: e.ac, }) } func (e paletteExec) OpenMoveTabPicker() error { target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) if target.tab == nil { return errMovePaneInvalid } return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{ Session: sessionMoveLocator(e.sess), TabID: domain.TabStableID(target.tab.stableID), Attachment: e.ac, }) } // openMovePicker opens one move picker on an admitted effect. The captured // source is daemon-side state: it is bound to the interaction at open and // revalidated at commit, never supplied by the presenting client. func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error { if e.ac == nil || e.ac.overlays == nil { return errAttachmentTransition } effect := e.effect if effect == nil { current := e.ac.transportSnapshot() _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport) if !admitted { return errAttachmentTransition } defer fresh.End() effect = fresh } if !e.redrawClosedPalette { return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0) } // The client overlay suppresses attachment output once the first move // snapshot arrives, so open only after the frame erasing the palette. // Refusals known now still fail the command synchronously. if err := e.d.movePickerPrecheck(e.sess, e.ac, intent, source); err != nil { return err } e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) { if err := e.d.openPickerForAttachment(e.ac, fresh, intent, source, 0); err != nil && !errors.Is(err, errAttachmentTransition) { e.d.reportError(e.sess, movePickerUserError(err)) } }) return nil } func (e paletteExec) focus(direction layout.Direction) error { return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) } func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) } func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) } func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) } func (e paletteExec) resize(axis layout.Axis, delta int) error { return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})) } func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) } func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) } func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) } func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) } func (e paletteExec) EqualizePanes() error { return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes})) } func (e paletteExec) NextTab() error { return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) } func (e paletteExec) PrevTab() error { return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab}) } func (e paletteExec) ToggleFloatingPane() error { return e.d.toggleFloating(e.sess, e.ac) } func (e paletteExec) BackSession() error { if e.effect != nil { return e.d.backSessionForAttachment(e.effect) } e.d.backSession(e.sess, e.ac) return nil } func (e paletteExec) Detach() error { if e.effect != nil { if !e.d.clientGoneForAttachment(e.effect, true) { return errAttachmentTransition } return nil } e.d.clientGone(e.sess, e.ac, e.ac.transport(), true) return nil } func (e paletteExec) EnterVisualMode() error { e.d.enterCopyMode(e.sess, e.ac) return nil } func (e paletteExec) RenameSession() error { e.sess.mu.Lock() currentName := e.sess.name e.sess.mu.Unlock() e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error { return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) }) return nil } func (e paletteExec) RenameSessionTo(name string) error { return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) } func (e paletteExec) RenameTab() error { tb := e.sess.tabForAttachment(e.ac) if e.ac == nil { tb = e.sess.firstTab() } if tb == nil { return nil } e.sess.mu.Lock() index := 0 for i, candidate := range e.sess.tabs { if candidate == tb { index = i break } } currentName := tabDisplayName(tb, index) e.sess.mu.Unlock() e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) }) return nil } func (e paletteExec) RenameTabTo(name string) error { tb := e.sess.tabForAttachment(e.ac) if e.ac == nil { tb = e.sess.firstTab() } if tb == nil { return nil } return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) } func (e paletteExec) OpenSessionPicker() error { if e.ac == nil { return errAttachmentTransition } effect := e.effect if effect == nil { current := e.ac.transportSnapshot() _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport) if !admitted { return errAttachmentTransition } defer fresh.End() effect = fresh } // Keep the attachment live: the client supervisor composes its own picker // over this attachment and owns its input until the user commits or // cancels. Detach-to-picker is reserved for attachment loss, session end, // and explicit detach. if !e.redrawClosedPalette { return e.d.offerClientNavigationPicker(e.ac, effect) } // The overlay suppresses attachment output, so the offer follows the // frame erasing the palette. e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) { if err := e.d.offerClientNavigationPicker(e.ac, fresh); err != nil && !errors.Is(err, errAttachmentTransition) { e.d.log.Error("palette command failed", "err", err, "command", "SSP") e.d.reportError(e.sess, err) } }) return nil } func (e paletteExec) OpenNotifications() error { e.d.enterNotices(e.sess, e.ac) return nil } func (e paletteExec) YankLastNotification() error { entry := e.attachment if entry == nil { entry = e.sess } n, ok := e.d.notices.latest() if !ok { // Reported directly rather than returned: the generic cmd.Run error // path only logs, so a returned error here would never reach the // user as the warn toast this no-op is supposed to produce. e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil)) return nil } e.d.yankNotice(entry, e.ac, n) return nil } func (e paletteExec) JumpRecentSession(rank int) error { history := historyRouteSnapshot(e.routeSnapshot) if history.Generation == 0 || rank < 1 || rank > len(history.Entries) { return command.ErrInvalidArguments } entry := history.Entries[rank-1] return e.NavigateRecentRoute(protocol.RouteNavigationAction{ SnapshotGeneration: e.routeSnapshot.Generation, Key: entry.Key, Generation: entry.Generation, }) } func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction) error { if e.d.beforeRecentSessionHandoff != nil { e.d.beforeRecentSessionHandoff() } if e.effect != nil { return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action) } if e.ac == nil || e.attachment == nil { return errAttachmentTransition } capability := e.attachment.captureAttachmentCapability(e.ac, e.ac.transport()) effect, admitted := e.ac.beginAttachmentEffect(capability) if !admitted { return errAttachmentTransition } defer effect.End() return e.d.sendRecentRouteNavigationActionForAttachment(effect, action) } func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { styleSet := resolveStyles(styles) modal := paletteModalFor(cfg) return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame { return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance}) }) }