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	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}

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