palette.go RawHistoryFolder

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

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