v0.9.1 ece7682

palette.go RawHistoryFolder

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	// decision holds the executed selection; execute reports one was made.
392	var decision paletteDecision
393	changed, cancel, execute, chooseDestination := false, false, false, false
394
395	ac.overlays.paletteMu.Lock()
396	if ac.overlays.palette == nil {
397		ac.overlays.palettePending = nil
398		ac.overlays.paletteMu.Unlock()
399		return paletteDecision{}
400	}
401	routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
402		rune: func(r rune) {
403			if !execute {
404				ac.overlays.palette.Insert(r)
405				changed = true
406			}
407		},
408		backspace: func() {
409			if !execute {
410				ac.overlays.palette.Backspace()
411				changed = true
412			}
413		},
414		tab: func() {
415			if !execute {
416				changed = ac.overlays.palette.CompleteSelected() || changed
417			}
418		},
419		up: func() {
420			if !execute {
421				ac.overlays.palette.Up()
422				changed = true
423			}
424		},
425		down: func() {
426			if !execute {
427				ac.overlays.palette.Down()
428				changed = true
429			}
430		},
431		cancel: func() {
432			if !execute {
433				cancel = true
434			}
435		},
436		enter: func() {
437			if execute {
438				return
439			}
440			selected, ok := ac.overlays.palette.Selected()
441			if !ok {
442				changed = true
443				chooseDestination = true
444				return
445			}
446			next := paletteDecision{rawQuery: ac.overlays.palette.Query(), generation: ac.overlays.paletteGeneration}
447			if cmd, ok := selected.Command(); ok {
448				next.kind, next.cmd = paletteDecisionCommand, cmd
449				if cmd.Arguments != command.ArgumentsNone {
450					action, valid := palette.ParseAction([]palette.Result{selected}, next.rawQuery)
451					if valid {
452						next.args = action.Args
453					} else if cmd.Arguments == command.ArgumentsRequired {
454						changed = true
455						return
456					}
457				}
458				if cmd.ContextHint == command.ContextHintRecentSessions {
459					next.routeSnapshot = ac.overlays.paletteRouteSnapshot
460					next.routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), next.routeSnapshot.Entries...)
461				}
462			} else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
463				next.kind, next.importedSourceKey, next.importedEntryKey = paletteDecisionImportedSession, sourceKey, entryKey
464			} else if action, ok := selected.RouteNavigationAction(); ok {
465				next.kind, next.routeTarget = paletteDecisionRouteNavigation, action
466			} else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok {
467				next.kind, next.createDestination = paletteDecisionCreateDestination, selected
468			} else if _, ok := selected.SessionTarget(); ok {
469				next.kind, next.sessionTarget = paletteDecisionSessionTarget, selected
470			} else {
471				changed = true
472				return
473			}
474			decision = next
475			execute = true
476		},
477	})
478	if changed {
479		ac.overlays.paletteFeedback = ""
480		if chooseDestination {
481			ac.overlays.paletteFeedback = "Choose a destination"
482		}
483		ac.overlays.palettePreview = ""
484		active, ok := ac.overlays.palette.ArgumentCommand()
485		if ok {
486			ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
487		}
488		if ok && active.Slug == "jump-recent-session" {
489			args := paletteArgs(ac.overlays.palette.Query(), active)
490			if ac.overlays.paletteRouteSnapshot.Generation != 0 {
491				ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
492			} else {
493				ac.overlays.paletteHints = palette.ContextualHints{}
494			}
495		} else {
496			ac.overlays.paletteHints = palette.ContextualHints{}
497		}
498	}
499	if cancel {
500		cancelled := paletteDecision{kind: paletteDecisionCancel}
501		cancelled.cancelInventoryInteraction, cancelled.hasCancelInventory = takePaletteInventoryClose(ac.overlays)
502		ac.clearPaletteLocked()
503		ac.overlays.paletteMu.Unlock()
504		return cancelled
505	}
506	ac.overlays.paletteMu.Unlock()
507
508	switch {
509	case execute:
510		return decision
511	case changed:
512		return paletteDecision{kind: paletteDecisionRedraw}
513	default:
514		return paletteDecision{}
515	}
516}
517
518func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
519	entry := ac.currentAttachmentSession()
520	if entry == nil {
521		return
522	}
523	var effect *attachmentEffect
524	if len(effects) != 0 {
525		effect = effects[0]
526	}
527	d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
528}
529
530// executePaletteDecision runs the outcome of decidePaletteInput with no palette
531// lock held.
532func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
533	generation, rawQuery := decision.generation, decision.rawQuery
534	switch decision.kind {
535	case paletteDecisionNone:
536	case paletteDecisionCancel:
537		if decision.hasCancelInventory {
538			d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
539		}
540		d.invalidateRender(entry, ac, true, "palette.go")
541	case paletteDecisionRedraw:
542		d.invalidateRender(entry, ac, true, "palette.go")
543	case paletteDecisionCreateDestination:
544		d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
545	case paletteDecisionImportedSession:
546		d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
547	case paletteDecisionRouteNavigation:
548		d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
549	case paletteDecisionSessionTarget:
550		d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
551	case paletteDecisionCommand:
552		d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot)
553	}
554}
555
556func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
557	name, _, _, _, _, _ := createDestination.CreateSessionDestination()
558	exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
559	if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
560		ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
561		d.invalidateRender(entry, ac, true, "palette.go")
562		return
563	}
564	if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
565		return
566	}
567	if name == "" {
568		d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
569			return exec.createSessionOnDestination(promptEffect, createDestination, name)
570		})
571		d.invalidateRender(entry, ac, true, "palette.go")
572		return
573	}
574	err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
575	if err != nil && !errors.Is(err, errAttachmentTransition) {
576		d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
577		return
578	}
579	if current := ac.currentAttachmentSession(); current != nil {
580		d.invalidateRender(current, ac, true, "palette.go")
581	}
582}
583
584func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
585	if effect == nil {
586		ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
587		d.invalidateRender(entry, ac, true, "palette.go")
588		return
589	}
590	if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
591		// Remote vision is read-only: imported remote routes surface
592		// availability but never attach.
593		ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
594		d.invalidateRender(entry, ac, true, "palette.go")
595		return
596	}
597	ac.overlays.paletteMu.Lock()
598	open := ac.overlays.paletteInventoryOpen
599	selection := protocol.NavigationInventorySelection{
600		CauseActionID:         effect.uiActionID,
601		InteractionGeneration: ac.overlays.paletteInventoryInteraction,
602		PublicationGeneration: ac.overlays.paletteInventoryPublication,
603		SourceKey:             importedSourceKey,
604		EntryKey:              importedEntryKey,
605	}
606	if open {
607		freezePaletteInventorySelection(ac.overlays, selection)
608	}
609	ac.overlays.paletteMu.Unlock()
610	if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
611		ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
612		d.invalidateRender(entry, ac, true, "palette.go")
613		return
614	}
615	// Selection crosses before the close demand on the guarded serving
616	// connection; closing after selection must not cancel the client's
617	// pending navigation operation.
618	if err := effect.sendControl(selection); err != nil {
619		ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
620		d.invalidateRender(entry, ac, true, "palette.go")
621		return
622	}
623	d.closeExecutedPalette(ac, effect, generation, rawQuery)
624}
625
626func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
627	exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
628	if err := exec.NavigateRecentRoute(routeTarget); err != nil {
629		if errors.Is(err, errAttachmentTransition) {
630			return
631		}
632		ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
633		d.invalidateRender(entry, ac, true, "palette.go")
634		return
635	}
636	if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
637		d.invalidateRender(entry, ac, true, "palette.go")
638	}
639}
640
641func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
642	createdAt, _ := sessionTarget.SessionCreatedAt()
643	expectedCreatedAt := createdAt.UnixNano()
644	name, _ := sessionTarget.SessionName()
645	exactTarget, _ := sessionTarget.SessionTarget()
646	target := picker.Target{
647		Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
648		ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
649	}
650	var err error
651	if effect != nil {
652		err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
653	} else {
654		err = d.switchToTarget(entry, ac, target)
655	}
656	if errors.Is(err, errAttachmentTransition) {
657		return
658	}
659	if err != nil {
660		ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
661		d.invalidateRender(entry, ac, true, "palette.go")
662		return
663	}
664	if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
665		if current := ac.currentAttachmentSession(); current != nil {
666			d.invalidateRender(current, ac, true, "palette.go")
667		}
668	}
669}
670
671func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) {
672	if cmd.Slug == "jump-recent-session" {
673		rank, err := command.ParsePositiveDecimal(args)
674		if err != nil {
675			ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
676			d.invalidateRender(entry, ac, true, "palette.go")
677			return
678		}
679		exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
680		if err := exec.JumpRecentSession(rank); err != nil {
681			if errors.Is(err, errAttachmentTransition) {
682				return
683			}
684			ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
685			d.invalidateRender(entry, ac, true, "palette.go")
686			return
687		}
688		if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
689			d.paletteHistory.record(cmd.Code)
690			if current := ac.currentAttachmentSession(); current != nil {
691				d.invalidateRender(current, ac, true, "palette.go")
692			}
693		}
694		return
695	}
696	attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
697	if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
698		return
699	}
700	entry.dispatchMu.Lock()
701	err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args)
702	entry.dispatchMu.Unlock()
703	if attachmentHandoff {
704		if current := ac.currentSession(); current != nil {
705			currentToken := current.captureAttachmentCapability(ac, ac.transport())
706			fresh, admitted := ac.beginAttachmentEffect(currentToken)
707			if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
708				d.invalidateRender(current, ac, true, "palette.go")
709			}
710			if admitted {
711				fresh.End()
712			}
713		} else {
714			d.closeExecutedPalette(ac, effect, generation, rawQuery)
715		}
716	}
717	if errors.Is(err, errAttachmentTransition) {
718		return
719	}
720	if err != nil {
721		d.log.Error("palette command failed", "err", err, "command", cmd.Code)
722		d.reportError(entry, paletteCommandNoticeError(cmd, err))
723	} else {
724		d.paletteHistory.record(cmd.Code)
725	}
726}
727
728func paletteCommandNoticeError(cmd command.Command, err error) error {
729	if cmd.Scope == command.CommandScopeCrossSession {
730		return movePickerUserError(err)
731	}
732	return err
733}
734
735func paletteArgs(query string, cmd command.Command) []string {
736	action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
737	if !ok {
738		return nil
739	}
740	return action.Args
741}
742
743// clearPaletteLocked requires ac.overlays.paletteMu.
744func (ac *attachedClient) clearPaletteLocked() {
745	ac.overlays.paletteGeneration++
746	ac.overlays.palette = nil
747	ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{}
748	ac.overlays.paletteHints = palette.ContextualHints{}
749	ac.overlays.palettePreview = ""
750	ac.overlays.paletteFeedback = ""
751	ac.overlays.palettePending = nil
752	closePaletteInventory(ac.overlays)
753}
754
755func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
756	ac.overlays.paletteMu.Lock()
757	defer ac.overlays.paletteMu.Unlock()
758	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
759		return
760	}
761	ac.overlays.paletteFeedback = feedback
762}
763
764func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool {
765	ac.overlays.paletteMu.Lock()
766	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
767		ac.overlays.paletteMu.Unlock()
768		return false
769	}
770	// Every successful close stops relay polling: take the close demand
771	// before clearing so no execute path leaks a 1/s poll loop.
772	closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays)
773	ac.clearPaletteLocked()
774	ac.overlays.paletteMu.Unlock()
775	if hasClose {
776		d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction)
777	}
778	return true
779}
780
781func (d *Daemon) closePalette(ac *attachedClient) {
782	d.closePaletteIfCurrent(ac, 0)
783}
784
785func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool {
786	if d == nil || ac == nil || ac.overlays == nil {
787		return false
788	}
789	ac.overlays.paletteMu.Lock()
790	if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation {
791		ac.overlays.paletteMu.Unlock()
792		return false
793	}
794	ac.clearPaletteLocked()
795	ac.overlays.paletteMu.Unlock()
796	return true
797}
798
799type paletteExec struct {
800	d                   *Daemon
801	sess                *session
802	attachment          *session
803	ac                  *attachedClient
804	routeSnapshot       protocol.RecentRouteSnapshot
805	actions             daemonActionRunner
806	effect              *attachmentEffect
807	redrawClosedPalette bool
808}
809
810func (e paletteExec) runAction(request daemonActionRequest) error {
811	request.effect = e.effect
812	changed, err := e.d.dispatchAction(daemonActionDispatch{
813		runner:      e.actions,
814		request:     request,
815		resolveFrom: e.sess,
816		attachment:  e.ac,
817		producer:    "palette.go",
818	})
819	if err == nil && !changed && e.redrawClosedPalette && e.d != nil {
820		// The palette closed before a no-op action; repaint what it covered.
821		e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
822	}
823	return err
824}
825
826func (e paletteExec) CreateTab() error {
827	return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
828}
829
830func (e paletteExec) CreateSession() error {
831	entry := e.attachment
832	if entry == nil {
833		entry = e.sess
834	}
835	e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error {
836		if effect == nil || effect.ac == nil {
837			if e.sess == nil {
838				return errAttachmentTransition
839			}
840			return e.d.createSessionAndSwitch(e.sess, e.ac, name)
841		}
842		return e.d.createSessionAndSwitchForAttachment(effect, name)
843	})
844	return nil
845}
846
847func (e paletteExec) CreateSessionNamed(name string) error {
848	if e.effect == nil {
849		return e.d.createSessionAndSwitch(e.sess, e.ac, name)
850	}
851	return e.d.createSessionAndSwitchForAttachment(e.effect, name)
852}
853
854func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
855	_, kind, _, _, route, ok := result.CreateSessionDestination()
856	if !ok {
857		return errCreateDestinationUnavailable
858	}
859	switch kind {
860	case palette.CreateSessionOnServingDaemon:
861		return nil
862	case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
863		// Another daemon is a client route: its reference must still name a
864		// daemon of the kind shown in the latest snapshot.
865		if e.ac == nil {
866			return errCreateDestinationUnavailable
867		}
868		snapshotGeneration, ok := result.CreateSessionSnapshotGeneration()
869		if !ok || snapshotGeneration == 0 {
870			return errCreateDestinationUnavailable
871		}
872		want := protocol.RouteKindLocal
873		if kind == palette.CreateSessionOnRemoteHost {
874			want = protocol.RouteKindRemote
875		}
876		snapshot := e.ac.routeSnapshotCopy()
877		got, valid := routeCreationKind(snapshot, route)
878		if !valid || snapshot.Generation != snapshotGeneration || got != want {
879			return errCreateDestinationUnavailable
880		}
881		if effect == nil && snapshot.Active != route {
882			return errCreateDestinationUnavailable
883		}
884		return nil
885	default:
886		return errCreateDestinationUnavailable
887	}
888}
889
890func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error {
891	if err := e.validateCreateSessionDestination(effect, result); err != nil {
892		return err
893	}
894	return e.createSessionOnValidatedDestination(effect, result, name)
895}
896
897func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error {
898	if err := domain.ValidateSessionName(name); err != nil {
899		return err
900	}
901	_, kind, _, _, route, _ := result.CreateSessionDestination()
902	if kind == palette.CreateSessionOnServingDaemon ||
903		(kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
904		if effect == nil {
905			return e.d.createSessionAndSwitch(e.sess, e.ac, name)
906		}
907		return e.d.createSessionAndSwitchForAttachment(effect, name)
908	}
909	if effect == nil {
910		return errAttachmentTransition
911	}
912	requestID := e.d.creationRequestSeq.Add(1)
913	if requestID == 0 {
914		requestID = e.d.creationRequestSeq.Add(1)
915	}
916	switch kind {
917	case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
918		// The client creates it through its broker and swaps to it.
919		snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
920		action := protocol.RouteCreateSessionAction{
921			RequestID: requestID, SnapshotGeneration: snapshotGeneration,
922			Key: route.Key, Generation: route.Generation, SessionName: name,
923		}
924		if err := effect.sendControl(action); err != nil {
925			return err
926		}
927		return nil
928	default:
929		return errAttachmentTransition
930	}
931}
932
933func (e paletteExec) CreateEphemeralSession() error {
934	if e.effect == nil {
935		return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
936	}
937	return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect)
938}
939
940func (e paletteExec) CloseTab() error {
941	return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
942}
943
944func (e paletteExec) split(direction layout.Direction) error {
945	return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
946}
947
948func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
949func (e paletteExec) SplitLeft() error  { return e.split(layout.Left) }
950func (e paletteExec) SplitUp() error    { return e.split(layout.Up) }
951func (e paletteExec) SplitDown() error  { return e.split(layout.Down) }
952func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
953	return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
954}
955func (e paletteExec) ConsumeOrExpelPaneLeft() error {
956	return e.consumeOrExpelPane(layout.Left)
957}
958func (e paletteExec) ConsumeOrExpelPaneRight() error {
959	return e.consumeOrExpelPane(layout.Right)
960}
961
962func (e paletteExec) StackPane() error {
963	return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
964}
965
966func (e paletteExec) ToggleStack() error {
967	return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
968}
969
970func (e paletteExec) ClosePane() error {
971	return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
972}
973
974func (e paletteExec) OpenMovePanePicker() error {
975	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
976	if target.tab == nil || target.pane == nil {
977		return errMovePaneInvalid
978	}
979	return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{
980		Session:    sessionMoveLocator(e.sess),
981		TabID:      domain.TabStableID(target.tab.stableID),
982		PaneID:     domain.PaneStableID(target.pane.stableID),
983		Attachment: e.ac,
984	})
985}
986
987func (e paletteExec) OpenMoveTabPicker() error {
988	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
989	if target.tab == nil {
990		return errMovePaneInvalid
991	}
992	return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{
993		Session:    sessionMoveLocator(e.sess),
994		TabID:      domain.TabStableID(target.tab.stableID),
995		Attachment: e.ac,
996	})
997}
998
999// openMovePicker opens one move picker on an admitted effect. The captured
1000// source is daemon-side state: it is bound to the interaction at open and
1001// revalidated at commit, never supplied by the presenting client.
1002func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error {
1003	if e.ac == nil || e.ac.overlays == nil {
1004		return errAttachmentTransition
1005	}
1006	effect := e.effect
1007	if effect == nil {
1008		current := e.ac.transportSnapshot()
1009		_, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1010		if !admitted {
1011			return errAttachmentTransition
1012		}
1013		defer fresh.End()
1014		effect = fresh
1015	}
1016	if !e.redrawClosedPalette {
1017		return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0)
1018	}
1019	// The client overlay suppresses attachment output once the first move
1020	// snapshot arrives, so open only after the frame erasing the palette.
1021	// Refusals known now still fail the command synchronously.
1022	if err := e.d.movePickerPrecheck(e.sess, e.ac, intent, source); err != nil {
1023		return err
1024	}
1025	e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1026		if err := e.d.openPickerForAttachment(e.ac, fresh, intent, source, 0); err != nil && !errors.Is(err, errAttachmentTransition) {
1027			e.d.reportError(e.sess, movePickerUserError(err))
1028		}
1029	})
1030	return nil
1031}
1032
1033func (e paletteExec) focus(direction layout.Direction) error {
1034	return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
1035}
1036
1037func (e paletteExec) FocusPaneLeft() error  { return e.focus(layout.Left) }
1038func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
1039func (e paletteExec) FocusPaneUp() error    { return e.focus(layout.Up) }
1040func (e paletteExec) FocusPaneDown() error  { return e.focus(layout.Down) }
1041
1042func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
1043func (e paletteExec) resize(axis layout.Axis, delta int) error {
1044	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
1045}
1046func (e paletteExec) GrowPaneWidth() error    { return e.resize(layout.Width, resizeStepCols) }
1047func (e paletteExec) ShrinkPaneWidth() error  { return e.resize(layout.Width, -resizeStepCols) }
1048func (e paletteExec) GrowPaneHeight() error   { return e.resize(layout.Height, resizeStepRows) }
1049func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
1050func (e paletteExec) EqualizePanes() error {
1051	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
1052}
1053
1054func (e paletteExec) NextTab() error {
1055	return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
1056}
1057
1058func (e paletteExec) PrevTab() error {
1059	return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
1060}
1061
1062func (e paletteExec) ToggleFloatingPane() error {
1063	return e.d.toggleFloating(e.sess, e.ac)
1064}
1065
1066func (e paletteExec) BackSession() error {
1067	if e.effect != nil {
1068		return e.d.backSessionForAttachment(e.effect)
1069	}
1070	e.d.backSession(e.sess, e.ac)
1071	return nil
1072}
1073
1074func (e paletteExec) Detach() error {
1075	if e.effect != nil {
1076		if !e.d.clientGoneForAttachment(e.effect, true) {
1077			return errAttachmentTransition
1078		}
1079		return nil
1080	}
1081	e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
1082	return nil
1083}
1084
1085func (e paletteExec) EnterVisualMode() error {
1086	e.d.enterCopyMode(e.sess, e.ac)
1087	return nil
1088}
1089
1090func (e paletteExec) RenameSession() error {
1091	e.sess.mu.Lock()
1092	currentName := e.sess.name
1093	e.sess.mu.Unlock()
1094	e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
1095		return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1096	})
1097	return nil
1098}
1099
1100func (e paletteExec) RenameSessionTo(name string) error {
1101	return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1102}
1103
1104func (e paletteExec) RenameTab() error {
1105	tb := e.sess.tabForAttachment(e.ac)
1106	if e.ac == nil {
1107		tb = e.sess.firstTab()
1108	}
1109	if tb == nil {
1110		return nil
1111	}
1112	e.sess.mu.Lock()
1113	index := 0
1114	for i, candidate := range e.sess.tabs {
1115		if candidate == tb {
1116			index = i
1117			break
1118		}
1119	}
1120	currentName := tabDisplayName(tb, index)
1121	e.sess.mu.Unlock()
1122	e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
1123		return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1124	})
1125	return nil
1126}
1127
1128func (e paletteExec) RenameTabTo(name string) error {
1129	tb := e.sess.tabForAttachment(e.ac)
1130	if e.ac == nil {
1131		tb = e.sess.firstTab()
1132	}
1133	if tb == nil {
1134		return nil
1135	}
1136	return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1137}
1138
1139func (e paletteExec) OpenSessionPicker() error {
1140	if e.ac == nil {
1141		return errAttachmentTransition
1142	}
1143	effect := e.effect
1144	if effect == nil {
1145		current := e.ac.transportSnapshot()
1146		_, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1147		if !admitted {
1148			return errAttachmentTransition
1149		}
1150		defer fresh.End()
1151		effect = fresh
1152	}
1153	// Keep the attachment live: the client supervisor composes its own picker
1154	// over this attachment and owns its input until the user commits or
1155	// cancels. Detach-to-picker is reserved for attachment loss, session end,
1156	// and explicit detach.
1157	if !e.redrawClosedPalette {
1158		return e.d.offerClientNavigationPicker(e.ac, effect)
1159	}
1160	// The overlay suppresses attachment output, so the offer follows the
1161	// frame erasing the palette.
1162	e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1163		if err := e.d.offerClientNavigationPicker(e.ac, fresh); err != nil && !errors.Is(err, errAttachmentTransition) {
1164			e.d.log.Error("palette command failed", "err", err, "command", "SSP")
1165			e.d.reportError(e.sess, err)
1166		}
1167	})
1168	return nil
1169}
1170
1171func (e paletteExec) OpenNotifications() error {
1172	e.d.enterNotices(e.sess, e.ac)
1173	return nil
1174}
1175
1176func (e paletteExec) YankLastNotification() error {
1177	entry := e.attachment
1178	if entry == nil {
1179		entry = e.sess
1180	}
1181	n, ok := e.d.notices.latest()
1182	if !ok {
1183		// Reported directly rather than returned: the generic cmd.Run error
1184		// path only logs, so a returned error here would never reach the
1185		// user as the warn toast this no-op is supposed to produce.
1186		e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
1187		return nil
1188	}
1189	e.d.yankNotice(entry, e.ac, n)
1190	return nil
1191}
1192
1193func (e paletteExec) JumpRecentSession(rank int) error {
1194	history := historyRouteSnapshot(e.routeSnapshot)
1195	if history.Generation == 0 || rank < 1 || rank > len(history.Entries) {
1196		return command.ErrInvalidArguments
1197	}
1198	entry := history.Entries[rank-1]
1199	return e.NavigateRecentRoute(protocol.RouteNavigationAction{
1200		SnapshotGeneration: e.routeSnapshot.Generation,
1201		Key:                entry.Key,
1202		Generation:         entry.Generation,
1203	})
1204}
1205
1206func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction) error {
1207	if e.d.beforeRecentSessionHandoff != nil {
1208		e.d.beforeRecentSessionHandoff()
1209	}
1210	if e.effect != nil {
1211		return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action)
1212	}
1213	if e.ac == nil || e.attachment == nil {
1214		return errAttachmentTransition
1215	}
1216	capability := e.attachment.captureAttachmentCapability(e.ac, e.ac.transport())
1217	effect, admitted := e.ac.beginAttachmentEffect(capability)
1218	if !admitted {
1219		return errAttachmentTransition
1220	}
1221	defer effect.End()
1222	return e.d.sendRecentRouteNavigationActionForAttachment(effect, action)
1223}
1224
1225func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
1226	styleSet := resolveStyles(styles)
1227	modal := paletteModalFor(cfg)
1228	return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
1229		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})
1230	})
1231}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close