1package daemon
2
3import (
4 "errors"
5 "sort"
6 "sync"
7 "time"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/palette"
15 "github.com/bnema/vev/internal/usecase/picker"
16 themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/internal/usecase/ui"
18)
19
20var (
21 // Floating palettes use a compact rail; below ui.ResponsiveDrawerBreakpoint
22 // the presentation becomes a full-width drawer regardless of this width.
23 paletteModal = ui.Modal{FixedWidth: 64, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottomRight, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
24 errCreateDestinationUnavailable = errors.New("that destination is no longer available")
25)
26
27func paletteModalFor(cfg domain.PaletteConfig) ui.Modal {
28 modal := paletteModal
29 if cfg.AnchorSet {
30 modal.Anchor = cfg.Anchor
31 }
32 return modal
33}
34
35func (d *Daemon) enterPalette(sess *session, ac *attachedClient) uint64 {
36 // Capture the client-owned route snapshot before taking paletteMu. The
37 // snapshot is immutable for this palette interaction and the daemon never
38 // consults its own session history for recent-route commands.
39 routeSnapshot := ac.routeSnapshotCopy()
40 commands := d.paletteCommands()
41 results := d.paletteResults(sess, commands, routeSnapshot)
42 model := palette.New(results)
43 ac.overlays.paletteMu.Lock()
44 ac.overlays.paletteGeneration++
45 ac.overlays.palette = model
46 ac.overlays.paletteRouteSnapshot = routeSnapshot
47 ac.overlays.paletteHints = palette.ContextualHints{}
48 ac.overlays.palettePreview = ""
49 ac.overlays.paletteFeedback = ""
50 ac.overlays.palettePending = nil
51 interaction := openPaletteInventory(ac.overlays)
52 ac.overlays.paletteMu.Unlock()
53 d.invalidateRender(sess, ac, true, "palette.go")
54 return interaction
55}
56
57type paletteSessionIdentity struct {
58 kind protocol.RouteKind
59 endpoint string
60 lifecycle domain.SessionLifecycleID
61}
62
63func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
64 return paletteSessionIdentity{kind: protocol.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
65}
66
67func paletteRemoteDisplayOrigin(hostLabel string) string {
68 origin := domain.RemoteDisplayOrigin(hostLabel)
69 if origin == "" {
70 return "remote"
71 }
72 return origin
73}
74
75func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) string {
76 active, ok := activeRouteEntryForLifecycle(snapshot, lifecycle)
77 if !ok || active.Kind != protocol.RouteKindRemote {
78 return ""
79 }
80 return paletteRemoteDisplayOrigin(active.HostLabel)
81}
82
83func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) {
84 if ref.IsZero() {
85 return protocol.RecentRouteEntry{}, false
86 }
87 if snapshot.Active == ref {
88 entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}
89 if entryRef == ref {
90 return snapshot.ActiveEntry, true
91 }
92 }
93 for _, entry := range snapshot.Entries {
94 if entry.Key == ref.Key && entry.Generation == ref.Generation {
95 return entry, true
96 }
97 }
98 return protocol.RecentRouteEntry{}, false
99}
100
101// createSessionDestinationResults lists where CNS may create a session: the
102// serving daemon itself, and every other daemon the client published as a
103// route host. Only the serving daemon creates in place; every
104// other destination is created by the client through its broker.
105func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID) []palette.Result {
106 results := make([]palette.Result, 0, len(snapshot.Hosts)+1)
107 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
108 results = append(results, palette.NewCreateSessionDestination(
109 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
110 ))
111 }
112 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
113 results = append(results, palette.NewCreateSessionDestination(
114 palette.CreateSessionOnServingDaemon, paletteRemoteDisplayOrigin(active.HostLabel), "", protocol.RouteRef{}, 0,
115 ))
116 }
117 for _, host := range snapshot.Hosts {
118 ref := protocol.RouteRef{Key: host.Key, Generation: host.Generation}
119 if host.Kind == protocol.RouteKindLocal {
120 results = append(results, palette.NewCreateSessionDestination(
121 palette.CreateSessionOnLocalRoute, "", "", ref, snapshot.Generation,
122 ))
123 continue
124 }
125 results = append(results, palette.NewCreateSessionDestination(
126 palette.CreateSessionOnRemoteHost, paletteRemoteDisplayOrigin(host.Label), "", ref, snapshot.Generation,
127 ))
128 }
129 return results
130}
131
132// routeHostForRef finds one creation host of the snapshot.
133func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) {
134 for _, host := range snapshot.Hosts {
135 if host.Key == ref.Key && host.Generation == ref.Generation && !ref.IsZero() {
136 return host, true
137 }
138 }
139 return protocol.RouteHost{}, false
140}
141
142// routeCreationKind reports the kind of daemon one creation reference names:
143// a route entry (or the active route) or a route host.
144func routeCreationKind(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteKind, bool) {
145 if entry, ok := routeEntryForRef(snapshot, ref); ok {
146 return entry.Kind, true
147 }
148 if host, ok := routeHostForRef(snapshot, ref); ok {
149 return host.Kind, true
150 }
151 return 0, false
152}
153
154func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
155 if entry.Kind == protocol.RouteKindLocal {
156 return true
157 }
158 return entry.Kind == protocol.RouteKindRemote && daemonDisplayOrigin != "" &&
159 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
160}
161
162// paletteResults projects the daemon's own inventory plus the client's route
163// snapshot for the palette. It keeps quick-switch exclusions, compact labels,
164// commands, and history behavior; other daemons' sessions and hosts come only
165// from the client routes, and committing one navigates through
166// the client.
167func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {
168 inv := d.captureSessionInventory(viewOptions{}, false)
169 stopped := inv.resumableStopped()
170 represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))
171 for _, entry := range inv.stopped {
172 represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{}
173 }
174 var currentLifecycle domain.SessionLifecycleID
175 if current != nil {
176 current.mu.Lock()
177 currentLifecycle = current.incarnation
178 current.mu.Unlock()
179 }
180 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
181 hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != ""
182 for _, entry := range routeSnapshot.Entries {
183 hybrid = hybrid || entry.Kind == protocol.RouteKindRemote
184 }
185 if daemonDisplayOrigin == "" && hybrid {
186 daemonDisplayOrigin = "local"
187 }
188
189 destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle)
190 results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+len(routeSnapshot.Entries))
191 for _, cmd := range commands {
192 results = append(results, palette.NewCommandResult(cmd))
193 }
194 results = append(results, destinations...)
195 active := make([]palette.Result, 0, len(inv.live))
196 for _, candidate := range inv.live {
197 snap := candidate.view
198 if snap.name == "" || snap.ephemeral {
199 continue
200 }
201 represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{}
202 if candidate.sess == current {
203 continue
204 }
205 target := protocol.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
206 active = append(active, palette.NewActiveSessionResultWithDisplayOrigin(target, time.Unix(0, snap.createdAt), daemonDisplayOrigin))
207 }
208 sort.Slice(active, func(i, j int) bool {
209 left, _ := active[i].SessionName()
210 right, _ := active[j].SessionName()
211 return left < right
212 })
213 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
214 results = append(results, active...)
215 for _, candidate := range stopped {
216 target := protocol.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
217 results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin))
218 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
219 }
220 for _, entry := range routeSnapshot.Entries {
221 if entry.Name == "" {
222 continue
223 }
224 origin := ""
225 if hybrid {
226 origin = "local"
227 }
228 if entry.Kind == protocol.RouteKindRemote {
229 origin = paletteRemoteDisplayOrigin(entry.HostLabel)
230 }
231 label := palette.SessionDisplay(entry.Name, origin)
232 identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
233 if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) {
234 continue
235 }
236 results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{
237 SnapshotGeneration: routeSnapshot.Generation,
238 Key: entry.Key,
239 Generation: entry.Generation,
240 }))
241 if entry.Kind == protocol.RouteKindLocal {
242 represented[identity] = struct{}{}
243 }
244 }
245 return results
246}
247
248func (d *Daemon) refreshPalette(ac *attachedClient) {
249 if d == nil || ac == nil || ac.overlays == nil {
250 return
251 }
252 sess := ac.currentAttachmentSession()
253 if sess == nil {
254 return
255 }
256 rt := ac.overlays
257 rt.paletteMu.Lock()
258 model := rt.palette
259 generation := rt.paletteGeneration
260 routeSnapshot := rt.paletteRouteSnapshot
261 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)
262 rt.paletteMu.Unlock()
263 if model == nil {
264 return
265 }
266
267 results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot)
268 results = appendImportedResults(results, ac)
269 rt.paletteMu.Lock()
270 defer rt.paletteMu.Unlock()
271 if rt.paletteGeneration != generation || rt.palette != model {
272 return
273 }
274 model.ReplaceResults(results)
275}
276
277func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints {
278 snapshot = historyRouteSnapshot(snapshot)
279 formatted := formatRecentRouteSnapshot(snapshot)
280 names := make([]string, len(formatted))
281 for i, entry := range formatted {
282 names[i] = entry.name
283 }
284 hints := palette.BuildRecentSessionHints(names, args)
285 for i, entry := range snapshot.Entries {
286 if i >= len(hints.Recent) {
287 break
288 }
289 hints.Recent[i].SnapshotGeneration = snapshot.Generation
290 hints.Recent[i].Key = entry.Key
291 hints.Recent[i].Generation = entry.Generation
292 }
293 return hints
294}
295
296// paletteHistory is the daemon-wide most-recently-used command order. mu is a
297// leaf lock: nothing is acquired while holding it. The zero value is usable.
298type paletteHistory struct {
299 mu sync.Mutex
300 recent []string
301}
302
303// snapshot returns a copy of the recent command codes, newest first.
304func (h *paletteHistory) snapshot() []string {
305 h.mu.Lock()
306 defer h.mu.Unlock()
307 return append([]string(nil), h.recent...)
308}
309
310// record moves code to the front of the recent list.
311func (h *paletteHistory) record(code string) {
312 h.mu.Lock()
313 defer h.mu.Unlock()
314
315 recent := make([]string, 0, len(h.recent)+1)
316 recent = append(recent, code)
317 for _, existing := range h.recent {
318 if existing != code {
319 recent = append(recent, existing)
320 }
321 }
322 h.recent = recent
323}
324
325func (d *Daemon) paletteCommands() []command.Command {
326 commands := command.PaletteRegistry()
327 recent := d.paletteHistory.snapshot()
328 overrides := d.codeOverrideSnapshot()
329
330 byCode := make(map[string]command.Command, len(commands))
331 for _, cmd := range commands {
332 cmd = commandWithOverrides(cmd, overrides)
333 byCode[cmd.Code] = cmd
334 }
335 out := make([]command.Command, 0, len(commands))
336 used := make(map[string]bool, len(recent))
337 for _, code := range recent {
338 cmd, ok := byCode[code]
339 if !ok || used[code] {
340 continue
341 }
342 out = append(out, cmd)
343 used[code] = true
344 }
345 for _, cmd := range commands {
346 cmd = commandWithOverrides(cmd, overrides)
347 if !used[cmd.Code] {
348 out = append(out, cmd)
349 }
350 }
351 return out
352}
353
354// paletteDecisionKind names what handlePaletteInput must do once the palette
355// lock is released.
356type paletteDecisionKind int
357
358const (
359 paletteDecisionNone paletteDecisionKind = iota
360 paletteDecisionRedraw
361 paletteDecisionCancel
362 paletteDecisionCreateDestination
363 paletteDecisionImportedSession
364 paletteDecisionRouteNavigation
365 paletteDecisionSessionTarget
366 paletteDecisionCommand
367)
368
369// paletteDecision is the immutable outcome of routing one input chunk through
370// the palette under paletteMu. Executing it never touches palette state without
371// retaking the lock.
372type paletteDecision struct {
373 kind paletteDecisionKind
374 generation uint64
375 rawQuery string
376 cmd command.Command
377 args []string
378 routeSnapshot protocol.RecentRouteSnapshot
379 sessionTarget palette.Result
380 importedSourceKey string
381 importedEntryKey string
382 routeTarget protocol.RouteNavigationAction
383 createDestination palette.Result
384 cancelInventoryInteraction uint64
385 hasCancelInventory bool
386}
387
388// decidePaletteInput takes paletteMu, applies data to the attachment's palette,
389// and returns the action to run after unlocking.
390func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
391 var cmd command.Command
392 var sessionTarget palette.Result
393 var hasSessionTarget bool
394 var importedSourceKey, importedEntryKey string
395 var hasImportedTarget bool
396 var routeTarget protocol.RouteNavigationAction
397 var hasRouteTarget bool
398 var createDestination palette.Result
399 var hasCreateDestination bool
400 var args []string
401 var routeSnapshot protocol.RecentRouteSnapshot
402 var generation uint64
403 var rawQuery string
404 var cancelInventoryInteraction uint64
405 var hasCancelInventory bool
406 changed, cancel, execute, chooseDestination := false, false, false, false
407
408 ac.overlays.paletteMu.Lock()
409 if ac.overlays.palette == nil {
410 ac.overlays.palettePending = nil
411 ac.overlays.paletteMu.Unlock()
412 return paletteDecision{}
413 }
414 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
415 rune: func(r rune) {
416 if !execute {
417 ac.overlays.palette.Insert(r)
418 changed = true
419 }
420 },
421 backspace: func() {
422 if !execute {
423 ac.overlays.palette.Backspace()
424 changed = true
425 }
426 },
427 tab: func() {
428 if !execute {
429 changed = ac.overlays.palette.CompleteSelected() || changed
430 }
431 },
432 up: func() {
433 if !execute {
434 ac.overlays.palette.Up()
435 changed = true
436 }
437 },
438 down: func() {
439 if !execute {
440 ac.overlays.palette.Down()
441 changed = true
442 }
443 },
444 cancel: func() {
445 if !execute {
446 cancel = true
447 }
448 },
449 enter: func() {
450 if execute {
451 return
452 }
453 selected, ok := ac.overlays.palette.Selected()
454 if !ok {
455 changed = true
456 chooseDestination = true
457 return
458 }
459 rawQuery = ac.overlays.palette.Query()
460 if selectedCommand, ok := selected.Command(); ok {
461 cmd = selectedCommand
462 if cmd.Arguments != command.ArgumentsNone {
463 action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
464 if valid {
465 args = action.Args
466 } else if cmd.Arguments == command.ArgumentsRequired {
467 changed = true
468 return
469 }
470 }
471 if cmd.ContextHint == command.ContextHintRecentSessions {
472 routeSnapshot = ac.overlays.paletteRouteSnapshot
473 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)
474 }
475 } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
476 importedSourceKey = sourceKey
477 importedEntryKey = entryKey
478 hasImportedTarget = true
479 } else if action, ok := selected.RouteNavigationAction(); ok {
480 routeTarget = action
481 hasRouteTarget = true
482 } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok {
483 createDestination = selected
484 hasCreateDestination = true
485 } else if _, ok := selected.SessionTarget(); ok {
486 sessionTarget = selected
487 hasSessionTarget = true
488 } else {
489 changed = true
490 return
491 }
492 generation = ac.overlays.paletteGeneration
493 execute = true
494 },
495 })
496 if changed {
497 ac.overlays.paletteFeedback = ""
498 if chooseDestination {
499 ac.overlays.paletteFeedback = "Choose a destination"
500 }
501 ac.overlays.palettePreview = ""
502 active, ok := ac.overlays.palette.ArgumentCommand()
503 if ok {
504 ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
505 }
506 if ok && active.Slug == "jump-recent-session" {
507 args := paletteArgs(ac.overlays.palette.Query(), active)
508 if ac.overlays.paletteRouteSnapshot.Generation != 0 {
509 ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
510 } else {
511 ac.overlays.paletteHints = palette.ContextualHints{}
512 }
513 } else {
514 ac.overlays.paletteHints = palette.ContextualHints{}
515 }
516 }
517 if cancel {
518 cancelInventoryInteraction, hasCancelInventory = takePaletteInventoryClose(ac.overlays)
519 ac.clearPaletteLocked()
520 }
521 ac.overlays.paletteMu.Unlock()
522
523 switch {
524 case cancel:
525 return paletteDecision{kind: paletteDecisionCancel, cancelInventoryInteraction: cancelInventoryInteraction, hasCancelInventory: hasCancelInventory}
526 case !execute && changed:
527 return paletteDecision{kind: paletteDecisionRedraw}
528 case !execute:
529 return paletteDecision{}
530 }
531 decision := paletteDecision{generation: generation, rawQuery: rawQuery}
532 switch {
533 case hasCreateDestination:
534 decision.kind = paletteDecisionCreateDestination
535 decision.createDestination = createDestination
536 case hasImportedTarget:
537 decision.kind = paletteDecisionImportedSession
538 decision.importedSourceKey = importedSourceKey
539 decision.importedEntryKey = importedEntryKey
540 case hasRouteTarget:
541 decision.kind = paletteDecisionRouteNavigation
542 decision.routeTarget = routeTarget
543 case hasSessionTarget:
544 decision.kind = paletteDecisionSessionTarget
545 decision.sessionTarget = sessionTarget
546 default:
547 decision.kind = paletteDecisionCommand
548 decision.cmd = cmd
549 decision.args = args
550 decision.routeSnapshot = routeSnapshot
551 }
552 return decision
553}
554
555func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
556 entry := ac.currentAttachmentSession()
557 if entry == nil {
558 return
559 }
560 var effect *attachmentEffect
561 if len(effects) != 0 {
562 effect = effects[0]
563 }
564 d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
565}
566
567// executePaletteDecision runs the outcome of decidePaletteInput with no palette
568// lock held.
569func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
570 generation, rawQuery := decision.generation, decision.rawQuery
571 switch decision.kind {
572 case paletteDecisionNone:
573 case paletteDecisionCancel:
574 if decision.hasCancelInventory {
575 d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
576 }
577 d.invalidateRender(entry, ac, true, "palette.go")
578 case paletteDecisionRedraw:
579 d.invalidateRender(entry, ac, true, "palette.go")
580 case paletteDecisionCreateDestination:
581 d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
582 case paletteDecisionImportedSession:
583 d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
584 case paletteDecisionRouteNavigation:
585 d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
586 case paletteDecisionSessionTarget:
587 d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
588 case paletteDecisionCommand:
589 d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot)
590 }
591}
592
593func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
594 name, _, _, _, _, _ := createDestination.CreateSessionDestination()
595 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
596 if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
597 ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
598 d.invalidateRender(entry, ac, true, "palette.go")
599 return
600 }
601 if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
602 return
603 }
604 if name == "" {
605 d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
606 return exec.createSessionOnDestination(promptEffect, createDestination, name)
607 })
608 d.invalidateRender(entry, ac, true, "palette.go")
609 return
610 }
611 err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
612 if err != nil && !errors.Is(err, errAttachmentTransition) {
613 d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
614 return
615 }
616 if current := ac.currentAttachmentSession(); current != nil {
617 d.invalidateRender(current, ac, true, "palette.go")
618 }
619}
620
621func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
622 if effect == nil {
623 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
624 d.invalidateRender(entry, ac, true, "palette.go")
625 return
626 }
627 if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
628 // Remote vision is read-only: imported remote routes surface
629 // availability but never attach.
630 ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
631 d.invalidateRender(entry, ac, true, "palette.go")
632 return
633 }
634 ac.overlays.paletteMu.Lock()
635 open := ac.overlays.paletteInventoryOpen
636 selection := protocol.NavigationInventorySelection{
637 CauseActionID: effect.uiActionID,
638 InteractionGeneration: ac.overlays.paletteInventoryInteraction,
639 PublicationGeneration: ac.overlays.paletteInventoryPublication,
640 SourceKey: importedSourceKey,
641 EntryKey: importedEntryKey,
642 }
643 if open {
644 freezePaletteInventorySelection(ac.overlays, selection)
645 }
646 ac.overlays.paletteMu.Unlock()
647 if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
648 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
649 d.invalidateRender(entry, ac, true, "palette.go")
650 return
651 }
652 // Selection crosses before the close demand on the guarded serving
653 // connection; closing after selection must not cancel the client's
654 // pending navigation operation.
655 if err := effect.sendControl(selection); err != nil {
656 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
657 d.invalidateRender(entry, ac, true, "palette.go")
658 return
659 }
660 d.closeExecutedPalette(ac, effect, generation, rawQuery)
661}
662
663func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
664 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
665 if err := exec.NavigateRecentRoute(routeTarget); err != nil {
666 if errors.Is(err, errAttachmentTransition) {
667 return
668 }
669 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
670 d.invalidateRender(entry, ac, true, "palette.go")
671 return
672 }
673 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
674 d.invalidateRender(entry, ac, true, "palette.go")
675 }
676}
677
678func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
679 createdAt, _ := sessionTarget.SessionCreatedAt()
680 expectedCreatedAt := createdAt.UnixNano()
681 name, _ := sessionTarget.SessionName()
682 exactTarget, _ := sessionTarget.SessionTarget()
683 target := picker.Target{
684 Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
685 ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
686 }
687 var err error
688 if effect != nil {
689 err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
690 } else {
691 err = d.switchToTarget(entry, ac, target)
692 }
693 if errors.Is(err, errAttachmentTransition) {
694 return
695 }
696 if err != nil {
697 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
698 d.invalidateRender(entry, ac, true, "palette.go")
699 return
700 }
701 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
702 if current := ac.currentAttachmentSession(); current != nil {
703 d.invalidateRender(current, ac, true, "palette.go")
704 }
705 }
706}
707
708func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) {
709 if cmd.Slug == "jump-recent-session" {
710 rank, err := command.ParsePositiveDecimal(args)
711 if err != nil {
712 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
713 d.invalidateRender(entry, ac, true, "palette.go")
714 return
715 }
716 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
717 if err := exec.JumpRecentSession(rank); err != nil {
718 if errors.Is(err, errAttachmentTransition) {
719 return
720 }
721 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
722 d.invalidateRender(entry, ac, true, "palette.go")
723 return
724 }
725 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
726 d.paletteHistory.record(cmd.Code)
727 if current := ac.currentAttachmentSession(); current != nil {
728 d.invalidateRender(current, ac, true, "palette.go")
729 }
730 }
731 return
732 }
733 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
734 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
735 return
736 }
737 entry.dispatchMu.Lock()
738 err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args)
739 entry.dispatchMu.Unlock()
740 if attachmentHandoff {
741 if current := ac.currentSession(); current != nil {
742 currentToken := current.captureAttachmentCapability(ac, ac.transport())
743 fresh, admitted := ac.beginAttachmentEffect(currentToken)
744 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
745 d.invalidateRender(current, ac, true, "palette.go")
746 }
747 if admitted {
748 fresh.End()
749 }
750 } else {
751 d.closeExecutedPalette(ac, effect, generation, rawQuery)
752 }
753 }
754 if errors.Is(err, errAttachmentTransition) {
755 return
756 }
757 if err != nil {
758 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
759 d.reportError(entry, paletteCommandNoticeError(cmd, err))
760 } else {
761 d.paletteHistory.record(cmd.Code)
762 }
763}
764
765func paletteCommandNoticeError(cmd command.Command, err error) error {
766 if cmd.Scope == command.CommandScopeCrossSession {
767 return movePickerUserError(err)
768 }
769 return err
770}
771
772func paletteArgs(query string, cmd command.Command) []string {
773 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
774 if !ok {
775 return nil
776 }
777 return action.Args
778}
779
780// clearPaletteLocked requires ac.overlays.paletteMu.
781func (ac *attachedClient) clearPaletteLocked() {
782 ac.overlays.paletteGeneration++
783 ac.overlays.palette = nil
784 ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{}
785 ac.overlays.paletteHints = palette.ContextualHints{}
786 ac.overlays.palettePreview = ""
787 ac.overlays.paletteFeedback = ""
788 ac.overlays.palettePending = nil
789 closePaletteInventory(ac.overlays)
790}
791
792func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
793 ac.overlays.paletteMu.Lock()
794 defer ac.overlays.paletteMu.Unlock()
795 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
796 return
797 }
798 ac.overlays.paletteFeedback = feedback
799}
800
801func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool {
802 ac.overlays.paletteMu.Lock()
803 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
804 ac.overlays.paletteMu.Unlock()
805 return false
806 }
807 // Every successful close stops relay polling: take the close demand
808 // before clearing so no execute path leaks a 1/s poll loop.
809 closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays)
810 ac.clearPaletteLocked()
811 ac.overlays.paletteMu.Unlock()
812 if hasClose {
813 d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction)
814 }
815 return true
816}
817
818func (d *Daemon) closePalette(ac *attachedClient) {
819 d.closePaletteIfCurrent(ac, 0)
820}
821
822func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool {
823 if d == nil || ac == nil || ac.overlays == nil {
824 return false
825 }
826 ac.overlays.paletteMu.Lock()
827 if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation {
828 ac.overlays.paletteMu.Unlock()
829 return false
830 }
831 ac.clearPaletteLocked()
832 ac.overlays.paletteMu.Unlock()
833 return true
834}
835
836type paletteExec struct {
837 d *Daemon
838 sess *session
839 attachment *session
840 ac *attachedClient
841 routeSnapshot protocol.RecentRouteSnapshot
842 actions daemonActionRunner
843 effect *attachmentEffect
844 redrawClosedPalette bool
845}
846
847func (e paletteExec) runAction(request daemonActionRequest) error {
848 request.effect = e.effect
849 changed, err := e.d.dispatchAction(daemonActionDispatch{
850 runner: e.actions,
851 request: request,
852 resolveFrom: e.sess,
853 attachment: e.ac,
854 producer: "palette.go",
855 })
856 if err == nil && !changed && e.redrawClosedPalette && e.d != nil {
857 // The palette closed before a no-op action; repaint what it covered.
858 e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
859 }
860 return err
861}
862
863func (e paletteExec) CreateTab() error {
864 return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
865}
866
867func (e paletteExec) CreateSession() error {
868 entry := e.attachment
869 if entry == nil {
870 entry = e.sess
871 }
872 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error {
873 if effect == nil || effect.ac == nil {
874 if e.sess == nil {
875 return errAttachmentTransition
876 }
877 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
878 }
879 return e.d.createSessionAndSwitchForAttachment(effect, name)
880 })
881 return nil
882}
883
884func (e paletteExec) CreateSessionNamed(name string) error {
885 if e.effect == nil {
886 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
887 }
888 return e.d.createSessionAndSwitchForAttachment(e.effect, name)
889}
890
891func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
892 _, kind, _, _, route, ok := result.CreateSessionDestination()
893 if !ok {
894 return errCreateDestinationUnavailable
895 }
896 switch kind {
897 case palette.CreateSessionOnServingDaemon:
898 return nil
899 case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
900 // Another daemon is a client route: its reference must still name a
901 // daemon of the kind shown in the latest snapshot.
902 if e.ac == nil {
903 return errCreateDestinationUnavailable
904 }
905 snapshotGeneration, ok := result.CreateSessionSnapshotGeneration()
906 if !ok || snapshotGeneration == 0 {
907 return errCreateDestinationUnavailable
908 }
909 want := protocol.RouteKindLocal
910 if kind == palette.CreateSessionOnRemoteHost {
911 want = protocol.RouteKindRemote
912 }
913 snapshot := e.ac.routeSnapshotCopy()
914 got, valid := routeCreationKind(snapshot, route)
915 if !valid || snapshot.Generation != snapshotGeneration || got != want {
916 return errCreateDestinationUnavailable
917 }
918 if effect == nil && snapshot.Active != route {
919 return errCreateDestinationUnavailable
920 }
921 return nil
922 default:
923 return errCreateDestinationUnavailable
924 }
925}
926
927func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error {
928 if err := e.validateCreateSessionDestination(effect, result); err != nil {
929 return err
930 }
931 return e.createSessionOnValidatedDestination(effect, result, name)
932}
933
934func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error {
935 if err := domain.ValidateSessionName(name); err != nil {
936 return err
937 }
938 _, kind, _, _, route, _ := result.CreateSessionDestination()
939 if kind == palette.CreateSessionOnServingDaemon ||
940 (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
941 if effect == nil {
942 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
943 }
944 return e.d.createSessionAndSwitchForAttachment(effect, name)
945 }
946 if effect == nil {
947 return errAttachmentTransition
948 }
949 requestID := e.d.creationRequestSeq.Add(1)
950 if requestID == 0 {
951 requestID = e.d.creationRequestSeq.Add(1)
952 }
953 switch kind {
954 case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
955 // The client creates it through its broker and swaps to it.
956 snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
957 action := protocol.RouteCreateSessionAction{
958 RequestID: requestID, SnapshotGeneration: snapshotGeneration,
959 Key: route.Key, Generation: route.Generation, SessionName: name,
960 }
961 if err := effect.sendControl(action); err != nil {
962 return err
963 }
964 return nil
965 default:
966 return errAttachmentTransition
967 }
968}
969
970func (e paletteExec) CreateEphemeralSession() error {
971 if e.effect == nil {
972 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
973 }
974 return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect)
975}
976
977func (e paletteExec) CloseTab() error {
978 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
979}
980
981func (e paletteExec) split(direction layout.Direction) error {
982 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
983}
984
985func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
986func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
987func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
988func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
989func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
990 return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
991}
992func (e paletteExec) ConsumeOrExpelPaneLeft() error {
993 return e.consumeOrExpelPane(layout.Left)
994}
995func (e paletteExec) ConsumeOrExpelPaneRight() error {
996 return e.consumeOrExpelPane(layout.Right)
997}
998
999func (e paletteExec) StackPane() error {
1000 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
1001}
1002
1003func (e paletteExec) ToggleStack() error {
1004 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
1005}
1006
1007func (e paletteExec) ClosePane() error {
1008 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
1009}
1010
1011func (e paletteExec) OpenMovePanePicker() error {
1012 target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
1013 if target.tab == nil || target.pane == nil {
1014 return errMovePaneInvalid
1015 }
1016 return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{
1017 Session: sessionMoveLocator(e.sess),
1018 TabID: domain.TabStableID(target.tab.stableID),
1019 PaneID: domain.PaneStableID(target.pane.stableID),
1020 Attachment: e.ac,
1021 })
1022}
1023
1024func (e paletteExec) OpenMoveTabPicker() error {
1025 target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
1026 if target.tab == nil {
1027 return errMovePaneInvalid
1028 }
1029 return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{
1030 Session: sessionMoveLocator(e.sess),
1031 TabID: domain.TabStableID(target.tab.stableID),
1032 Attachment: e.ac,
1033 })
1034}
1035
1036// openMovePicker opens one move picker on an admitted effect. The captured
1037// source is daemon-side state: it is bound to the interaction at open and
1038// revalidated at commit, never supplied by the presenting client.
1039func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error {
1040 if e.ac == nil || e.ac.overlays == nil {
1041 return errAttachmentTransition
1042 }
1043 effect := e.effect
1044 if effect == nil {
1045 current := e.ac.transportSnapshot()
1046 _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1047 if !admitted {
1048 return errAttachmentTransition
1049 }
1050 defer fresh.End()
1051 effect = fresh
1052 }
1053 if !e.redrawClosedPalette {
1054 return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0)
1055 }
1056 // The client overlay suppresses attachment output once the first move
1057 // snapshot arrives, so open only after the frame erasing the palette.
1058 // Refusals known now still fail the command synchronously.
1059 if err := e.d.movePickerPrecheck(e.sess, e.ac, intent, source); err != nil {
1060 return err
1061 }
1062 e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1063 if err := e.d.openPickerForAttachment(e.ac, fresh, intent, source, 0); err != nil && !errors.Is(err, errAttachmentTransition) {
1064 e.d.reportError(e.sess, movePickerUserError(err))
1065 }
1066 })
1067 return nil
1068}
1069
1070func (e paletteExec) focus(direction layout.Direction) error {
1071 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
1072}
1073
1074func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
1075func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
1076func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
1077func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
1078
1079func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
1080func (e paletteExec) resize(axis layout.Axis, delta int) error {
1081 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
1082}
1083func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
1084func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
1085func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
1086func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
1087func (e paletteExec) EqualizePanes() error {
1088 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
1089}
1090
1091func (e paletteExec) NextTab() error {
1092 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
1093}
1094
1095func (e paletteExec) PrevTab() error {
1096 return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
1097}
1098
1099func (e paletteExec) ToggleFloatingPane() error {
1100 return e.d.toggleFloating(e.sess, e.ac)
1101}
1102
1103func (e paletteExec) BackSession() error {
1104 if e.effect != nil {
1105 return e.d.backSessionForAttachment(e.effect)
1106 }
1107 e.d.backSession(e.sess, e.ac)
1108 return nil
1109}
1110
1111func (e paletteExec) Detach() error {
1112 if e.effect != nil {
1113 if !e.d.clientGoneForAttachment(e.effect, true) {
1114 return errAttachmentTransition
1115 }
1116 return nil
1117 }
1118 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
1119 return nil
1120}
1121
1122func (e paletteExec) EnterVisualMode() error {
1123 e.d.enterCopyMode(e.sess, e.ac)
1124 return nil
1125}
1126
1127func (e paletteExec) RenameSession() error {
1128 e.sess.mu.Lock()
1129 currentName := e.sess.name
1130 e.sess.mu.Unlock()
1131 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
1132 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1133 })
1134 return nil
1135}
1136
1137func (e paletteExec) RenameSessionTo(name string) error {
1138 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1139}
1140
1141func (e paletteExec) RenameTab() error {
1142 tb := e.sess.tabForAttachment(e.ac)
1143 if e.ac == nil {
1144 tb = e.sess.firstTab()
1145 }
1146 if tb == nil {
1147 return nil
1148 }
1149 e.sess.mu.Lock()
1150 index := 0
1151 for i, candidate := range e.sess.tabs {
1152 if candidate == tb {
1153 index = i
1154 break
1155 }
1156 }
1157 currentName := tabDisplayName(tb, index)
1158 e.sess.mu.Unlock()
1159 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
1160 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1161 })
1162 return nil
1163}
1164
1165func (e paletteExec) RenameTabTo(name string) error {
1166 tb := e.sess.tabForAttachment(e.ac)
1167 if e.ac == nil {
1168 tb = e.sess.firstTab()
1169 }
1170 if tb == nil {
1171 return nil
1172 }
1173 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1174}
1175
1176func (e paletteExec) OpenSessionPicker() error {
1177 if e.ac == nil {
1178 return errAttachmentTransition
1179 }
1180 effect := e.effect
1181 if effect == nil {
1182 current := e.ac.transportSnapshot()
1183 _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1184 if !admitted {
1185 return errAttachmentTransition
1186 }
1187 defer fresh.End()
1188 effect = fresh
1189 }
1190 // Keep the attachment live: the client supervisor composes its own picker
1191 // over this attachment and owns its input until the user commits or
1192 // cancels. Detach-to-picker is reserved for attachment loss, session end,
1193 // and explicit detach.
1194 if !e.redrawClosedPalette {
1195 return e.d.offerClientNavigationPicker(e.ac, effect)
1196 }
1197 // The overlay suppresses attachment output, so the offer follows the
1198 // frame erasing the palette.
1199 e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1200 if err := e.d.offerClientNavigationPicker(e.ac, fresh); err != nil && !errors.Is(err, errAttachmentTransition) {
1201 e.d.log.Error("palette command failed", "err", err, "command", "SSP")
1202 e.d.reportError(e.sess, err)
1203 }
1204 })
1205 return nil
1206}
1207
1208func (e paletteExec) OpenNotifications() error {
1209 e.d.enterNotices(e.sess, e.ac)
1210 return nil
1211}
1212
1213func (e paletteExec) YankLastNotification() error {
1214 entry := e.attachment
1215 if entry == nil {
1216 entry = e.sess
1217 }
1218 n, ok := e.d.notices.latest()
1219 if !ok {
1220 // Reported directly rather than returned: the generic cmd.Run error
1221 // path only logs, so a returned error here would never reach the
1222 // user as the warn toast this no-op is supposed to produce.
1223 e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
1224 return nil
1225 }
1226 e.d.yankNotice(entry, e.ac, n)
1227 return nil
1228}
1229
1230func (e paletteExec) JumpRecentSession(rank int) error {
1231 history := historyRouteSnapshot(e.routeSnapshot)
1232 if history.Generation == 0 || rank < 1 || rank > len(history.Entries) {
1233 return command.ErrInvalidArguments
1234 }
1235 entry := history.Entries[rank-1]
1236 return e.NavigateRecentRoute(protocol.RouteNavigationAction{
1237 SnapshotGeneration: e.routeSnapshot.Generation,
1238 Key: entry.Key,
1239 Generation: entry.Generation,
1240 })
1241}
1242
1243func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction) error {
1244 if e.d.beforeRecentSessionHandoff != nil {
1245 e.d.beforeRecentSessionHandoff()
1246 }
1247 if e.effect != nil {
1248 return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action)
1249 }
1250 if e.ac == nil || e.attachment == nil {
1251 return errAttachmentTransition
1252 }
1253 capability := e.attachment.captureAttachmentCapability(e.ac, e.ac.transport())
1254 effect, admitted := e.ac.beginAttachmentEffect(capability)
1255 if !admitted {
1256 return errAttachmentTransition
1257 }
1258 defer effect.End()
1259 return e.d.sendRecentRouteNavigationActionForAttachment(effect, action)
1260}
1261
1262func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
1263 styleSet := resolveStyles(styles)
1264 modal := paletteModalFor(cfg)
1265 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
1266 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})
1267 })
1268}