v0.3.0 99dc266

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/ports"
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
19const (
20	paletteRailBreakpoint = 96
21	paletteRailWidth      = 64
22)
23
24var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
25
26func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
27	modal := paletteModal
28	if size.Cols >= paletteRailBreakpoint {
29		modal.FixedWidth = paletteRailWidth
30	}
31	if cfg.AnchorSet {
32		modal.Anchor = cfg.Anchor
33	} else if size.Cols >= paletteRailBreakpoint {
34		modal.Anchor = domain.AnchorBottomRight
35	}
36	return modal
37}
38
39func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
40	// Capture the client-owned route snapshot before taking paletteMu. The
41	// snapshot is immutable for this palette interaction and the daemon never
42	// consults its own session history for recent-route commands.
43	routeSnapshot := ac.routeSnapshotCopy()
44	commands := d.paletteCommands()
45	results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
46	ac.overlays.paletteMu.Lock()
47	ac.overlays.paletteGeneration++
48	ac.overlays.palette = palette.New(results)
49	ac.overlays.paletteRouteSnapshot = routeSnapshot
50	ac.overlays.paletteHints = palette.ContextualHints{}
51	ac.overlays.palettePreview = ""
52	ac.overlays.paletteFeedback = ""
53	ac.overlays.palettePending = nil
54	ac.overlays.paletteMu.Unlock()
55	d.invalidateRender(sess, ac, true, "palette.go")
56}
57
58// paletteResults captures eligible named sessions before paletteMu so the
59// palette has an immutable lifecycle target without violating lock ordering.
60func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
61	d.mu.Lock()
62	sessions := d.sessionsSnapshotLocked()
63	stopped := make([]stoppedSession, 0, len(d.stopped))
64	for _, entry := range d.stopped {
65		if !entry.purging {
66			stopped = append(stopped, entry)
67		}
68	}
69	d.mu.Unlock()
70
71	results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
72	for _, cmd := range commands {
73		results = append(results, palette.NewCommandResult(cmd))
74	}
75	active := make([]palette.Result, 0, len(sessions))
76	for _, candidate := range sessions {
77		if candidate == current {
78			continue
79		}
80		snap := candidate.snapshotView(viewOptions{})
81		if snap.name != "" && !snap.ephemeral {
82			active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
83		}
84	}
85	sort.Slice(active, func(i, j int) bool {
86		left, _ := active[i].SessionName()
87		right, _ := active[j].SessionName()
88		return left < right
89	})
90	sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
91	results = append(results, active...)
92	localNames := make(map[string]struct{}, len(active)+len(stopped))
93	for _, candidate := range active {
94		name, _ := candidate.SessionName()
95		localNames[name] = struct{}{}
96	}
97	for _, candidate := range stopped {
98		results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
99		localNames[candidate.name] = struct{}{}
100	}
101	formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
102	for i, entry := range routeSnapshot.Entries {
103		if i >= len(formattedRoutes) {
104			break
105		}
106		label := formattedRoutes[i].name
107		if label == "" {
108			continue
109		}
110		if _, represented := localNames[entry.Name]; represented {
111			sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||
112				(entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin)
113			if sameOrigin {
114				continue
115			}
116			if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" {
117				label = formatRecentRouteName(recentRoutePresentation{
118					name:      entry.Name,
119					kind:      recentRouteLocal,
120					ephemeral: entry.Ephemeral,
121				}, true)
122			}
123		}
124		results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
125			SnapshotGeneration: routeSnapshot.Generation,
126			Key:                entry.Key,
127			Generation:         entry.Generation,
128		}))
129	}
130	return results
131}
132
133func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
134	formatted := formatRecentRouteSnapshot(snapshot)
135	names := make([]string, len(formatted))
136	for i, entry := range formatted {
137		names[i] = entry.name
138	}
139	hints := palette.BuildRecentSessionHints(names, args)
140	for i, entry := range snapshot.Entries {
141		if i >= len(hints.Recent) {
142			break
143		}
144		hints.Recent[i].SnapshotGeneration = snapshot.Generation
145		hints.Recent[i].Key = entry.Key
146		hints.Recent[i].Generation = entry.Generation
147	}
148	return hints
149}
150
151func (d *Daemon) paletteCommands() []command.Command {
152	commands := command.PaletteRegistry()
153	d.paletteRecentMu.Lock()
154	recent := append([]string(nil), d.paletteRecent...)
155	d.paletteRecentMu.Unlock()
156	overrides := d.codeOverrideSnapshot()
157
158	byCode := make(map[string]command.Command, len(commands))
159	for _, cmd := range commands {
160		cmd = commandWithOverrides(cmd, overrides)
161		byCode[cmd.Code] = cmd
162	}
163	out := make([]command.Command, 0, len(commands))
164	used := make(map[string]bool, len(recent))
165	for _, code := range recent {
166		cmd, ok := byCode[code]
167		if !ok || used[code] {
168			continue
169		}
170		out = append(out, cmd)
171		used[code] = true
172	}
173	for _, cmd := range commands {
174		cmd = commandWithOverrides(cmd, overrides)
175		if !used[cmd.Code] {
176			out = append(out, cmd)
177		}
178	}
179	return out
180}
181
182func (d *Daemon) recordPaletteUse(code string) {
183	d.paletteRecentMu.Lock()
184	defer d.paletteRecentMu.Unlock()
185
186	recent := make([]string, 0, len(d.paletteRecent)+1)
187	recent = append(recent, code)
188	for _, existing := range d.paletteRecent {
189		if existing != code {
190			recent = append(recent, existing)
191		}
192	}
193	d.paletteRecent = recent
194}
195
196func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
197	entry := ac.currentAttachmentSession()
198	if entry == nil {
199		return
200	}
201	sess := entry
202	var cmd command.Command
203	var sessionTarget palette.Result
204	var hasSessionTarget bool
205	var routeTarget ports.RouteNavigationAction
206	var hasRouteTarget bool
207	var args []string
208	var routeSnapshot ports.RecentRouteSnapshot
209	var generation uint64
210	var rawQuery string
211	changed, cancel, execute := false, false, false
212	var effect *attachmentEffectTicket
213	if len(effects) != 0 {
214		effect = effects[0]
215	}
216
217	ac.overlays.paletteMu.Lock()
218	if ac.overlays.palette == nil {
219		ac.overlays.palettePending = nil
220		ac.overlays.paletteMu.Unlock()
221		return
222	}
223	routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
224		rune: func(r rune) {
225			if !execute {
226				ac.overlays.palette.Insert(r)
227				changed = true
228			}
229		},
230		backspace: func() {
231			if !execute {
232				ac.overlays.palette.Backspace()
233				changed = true
234			}
235		},
236		tab: func() {
237			if !execute {
238				changed = ac.overlays.palette.CompleteSelected() || changed
239			}
240		},
241		up: func() {
242			if !execute {
243				ac.overlays.palette.Up()
244				changed = true
245			}
246		},
247		down: func() {
248			if !execute {
249				ac.overlays.palette.Down()
250				changed = true
251			}
252		},
253		cancel: func() {
254			if !execute {
255				cancel = true
256			}
257		},
258		enter: func() {
259			if execute {
260				return
261			}
262			selected, ok := ac.overlays.palette.Selected()
263			if !ok {
264				changed = true
265				return
266			}
267			rawQuery = ac.overlays.palette.Query()
268			if selectedCommand, ok := selected.Command(); ok {
269				cmd = selectedCommand
270				if cmd.Arguments != command.ArgumentsNone {
271					action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
272					if valid {
273						args = action.Args
274					} else if cmd.Arguments == command.ArgumentsRequired {
275						changed = true
276						return
277					}
278				}
279				if cmd.ContextHint == command.ContextHintRecentSessions {
280					routeSnapshot = ac.overlays.paletteRouteSnapshot
281					routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
282				}
283			} else if action, ok := selected.RouteNavigationAction(); ok {
284				routeTarget = action
285				hasRouteTarget = true
286			} else if _, ok := selected.SessionName(); ok {
287				sessionTarget = selected
288				hasSessionTarget = true
289			} else {
290				changed = true
291				return
292			}
293			generation = ac.overlays.paletteGeneration
294			execute = true
295		},
296	})
297	if changed {
298		ac.overlays.paletteFeedback = ""
299		ac.overlays.palettePreview = ""
300		active, ok := ac.overlays.palette.ArgumentCommand()
301		if ok {
302			ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
303		}
304		if ok && active.Slug == "jump-recent-session" {
305			args := paletteArgs(ac.overlays.palette.Query(), active)
306			if ac.overlays.paletteRouteSnapshot.Generation != 0 {
307				ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
308			} else {
309				ac.overlays.paletteHints = palette.ContextualHints{}
310			}
311		} else {
312			ac.overlays.paletteHints = palette.ContextualHints{}
313		}
314	}
315	if cancel {
316		ac.clearPaletteLocked()
317	}
318	ac.overlays.paletteMu.Unlock()
319	if cancel {
320		d.invalidateRender(entry, ac, true, "palette.go")
321		return
322	}
323	if !execute {
324		if changed {
325			d.invalidateRender(entry, ac, true, "palette.go")
326		}
327		return
328	}
329
330	if hasRouteTarget {
331		exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
332		if err := exec.NavigateRecentRoute(routeTarget); err != nil {
333			if errors.Is(err, errAttachmentTransition) {
334				return
335			}
336			ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
337			d.invalidateRender(entry, ac, true, "palette.go")
338			return
339		}
340		if ac.closeExecutedPalette(generation, rawQuery) {
341			d.invalidateRender(entry, ac, true, "palette.go")
342		}
343		return
344	}
345
346	if hasSessionTarget {
347		createdAt, _ := sessionTarget.SessionCreatedAt()
348		expectedCreatedAt := createdAt.UnixNano()
349		name, _ := sessionTarget.SessionName()
350		target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}
351		if id, active := sessionTarget.SessionID(); active {
352			target.Session = id
353		} else {
354			target.Stopped = true
355		}
356		var err error
357		if effect != nil {
358			err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
359		} else {
360			err = d.switchToTarget(sess, ac, target)
361		}
362		if errors.Is(err, errAttachmentTransition) {
363			return
364		}
365		if err != nil {
366			ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
367			d.invalidateRender(entry, ac, true, "palette.go")
368			return
369		}
370		if ac.closeExecutedPalette(generation, rawQuery) {
371			if current := ac.currentAttachmentSession(); current != nil {
372				d.invalidateRender(current, ac, true, "palette.go")
373			}
374		}
375		return
376	}
377
378	if cmd.Slug == "jump-recent-session" {
379		rank, err := command.ParsePositiveDecimal(args)
380		if err != nil {
381			ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
382			d.invalidateRender(entry, ac, true, "palette.go")
383			return
384		}
385		exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
386		if err := exec.JumpRecentSession(rank); err != nil {
387			if errors.Is(err, errAttachmentTransition) {
388				return
389			}
390			ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
391			d.invalidateRender(entry, ac, true, "palette.go")
392			return
393		}
394		if ac.closeExecutedPalette(generation, rawQuery) {
395			d.recordPaletteUse(cmd.Code)
396			if current := ac.currentAttachmentSession(); current != nil {
397				d.invalidateRender(current, ac, true, "palette.go")
398			}
399		}
400		return
401	}
402	attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
403	if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
404		return
405	}
406	sess.dispatchMu.Lock()
407	err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
408	sess.dispatchMu.Unlock()
409	if attachmentHandoff {
410		if current := ac.currentSession(); current != nil {
411			currentToken := current.attachmentToken(ac, ac.transport())
412			fresh, admitted := ac.beginAttachmentEffect(currentToken)
413			if ac.closeExecutedPalette(generation, rawQuery) {
414				d.invalidateRender(current, ac, true, "palette.go")
415			}
416			if admitted {
417				fresh.End()
418			}
419		} else {
420			ac.closeExecutedPalette(generation, rawQuery)
421		}
422	}
423	if errors.Is(err, errAttachmentTransition) {
424		return
425	}
426	if err != nil {
427		d.log.Error("palette command failed", "err", err, "command", cmd.Code)
428		d.reportError(sess, paletteCommandNoticeError(cmd, err))
429	} else {
430		d.recordPaletteUse(cmd.Code)
431	}
432}
433
434func paletteCommandNoticeError(cmd command.Command, err error) error {
435	if cmd.Scope == command.CommandScopeCrossSession {
436		return movePickerUserError(err)
437	}
438	return err
439}
440
441func paletteArgs(query string, cmd command.Command) []string {
442	action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
443	if !ok {
444		return nil
445	}
446	return action.Args
447}
448
449func (ac *attachedClient) clearPaletteLocked() {
450	ac.overlays.paletteGeneration++
451	ac.overlays.palette = nil
452	ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
453	ac.overlays.paletteHints = palette.ContextualHints{}
454	ac.overlays.palettePreview = ""
455	ac.overlays.paletteFeedback = ""
456	ac.overlays.palettePending = nil
457}
458
459func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
460	ac.overlays.paletteMu.Lock()
461	defer ac.overlays.paletteMu.Unlock()
462	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
463		return
464	}
465	ac.overlays.paletteFeedback = feedback
466}
467
468func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
469	ac.overlays.paletteMu.Lock()
470	defer ac.overlays.paletteMu.Unlock()
471	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
472		return false
473	}
474	ac.clearPaletteLocked()
475	return true
476}
477
478type paletteExec struct {
479	d                   *Daemon
480	sess                *session
481	attachment          *session
482	ac                  *attachedClient
483	routeSnapshot       ports.RecentRouteSnapshot
484	actions             daemonActionRunner
485	effect              *attachmentEffectTicket
486	redrawClosedPalette bool
487}
488
489func (e paletteExec) runAction(request daemonActionRequest) error {
490	request.effect = e.effect
491	if request.target.session == nil {
492		request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
493	}
494	runner := e.actions
495	if runner == nil {
496		runner = daemonActions{d: e.d}
497	}
498	err := runner.Run(request)
499	if errors.Is(err, errDaemonActionNoChange) {
500		if e.redrawClosedPalette {
501			e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
502		}
503		return nil
504	}
505	if err != nil {
506		return err
507	}
508	if e.actions == nil {
509		finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
510	}
511	return nil
512}
513
514func (e paletteExec) CreateTab() error {
515	return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
516}
517
518func (e paletteExec) CreateSession() error {
519	entry := e.attachment
520	if entry == nil {
521		entry = e.sess
522	}
523	e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
524		if token.ac == nil {
525			if e.sess == nil {
526				return errAttachmentTransition
527			}
528			return e.d.createSessionAndSwitch(e.sess, e.ac, name)
529		}
530		return e.d.createSessionAndSwitchForAttachment(token, name)
531	})
532	return nil
533}
534
535func (e paletteExec) CreateSessionNamed(name string) error {
536	if e.effect == nil {
537		return e.d.createSessionAndSwitch(e.sess, e.ac, name)
538	}
539	return e.d.createSessionAndSwitchForAttachment(e.effect.connectionToken(), name)
540}
541
542func (e paletteExec) CreateEphemeralSession() error {
543	if e.effect == nil {
544		return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
545	}
546	return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect.connectionToken())
547}
548
549func (e paletteExec) CloseTab() error {
550	return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
551}
552
553func (e paletteExec) split(direction layout.Direction) error {
554	return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
555}
556
557func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
558func (e paletteExec) SplitLeft() error  { return e.split(layout.Left) }
559func (e paletteExec) SplitUp() error    { return e.split(layout.Up) }
560func (e paletteExec) SplitDown() error  { return e.split(layout.Down) }
561func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
562	return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
563}
564func (e paletteExec) ConsumeOrExpelPaneLeft() error {
565	return e.consumeOrExpelPane(layout.Left)
566}
567func (e paletteExec) ConsumeOrExpelPaneRight() error {
568	return e.consumeOrExpelPane(layout.Right)
569}
570
571func (e paletteExec) StackPane() error {
572	return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
573}
574
575func (e paletteExec) ToggleStack() error {
576	return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
577}
578
579func (e paletteExec) ClosePane() error {
580	return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
581}
582
583func (e paletteExec) OpenMovePanePicker() error {
584	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
585	if target.tab == nil || target.pane == nil {
586		return errMovePaneInvalid
587	}
588	return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
589		Session:    sessionMoveLocator(e.sess),
590		TabID:      domain.TabStableID(target.tab.stableID),
591		PaneID:     domain.PaneStableID(target.pane.stableID),
592		Attachment: e.ac,
593	})
594}
595
596func (e paletteExec) OpenMoveTabPicker() error {
597	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
598	if target.tab == nil {
599		return errMovePaneInvalid
600	}
601	return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
602		Session:    sessionMoveLocator(e.sess),
603		TabID:      domain.TabStableID(target.tab.stableID),
604		Attachment: e.ac,
605	})
606}
607
608func (e paletteExec) focus(direction layout.Direction) error {
609	return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
610}
611
612func (e paletteExec) FocusPaneLeft() error  { return e.focus(layout.Left) }
613func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
614func (e paletteExec) FocusPaneUp() error    { return e.focus(layout.Up) }
615func (e paletteExec) FocusPaneDown() error  { return e.focus(layout.Down) }
616
617func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
618func (e paletteExec) resize(axis layout.Axis, delta int) error {
619	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
620}
621func (e paletteExec) GrowPaneWidth() error    { return e.resize(layout.Width, resizeStepCols) }
622func (e paletteExec) ShrinkPaneWidth() error  { return e.resize(layout.Width, -resizeStepCols) }
623func (e paletteExec) GrowPaneHeight() error   { return e.resize(layout.Height, resizeStepRows) }
624func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
625func (e paletteExec) EqualizePanes() error {
626	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
627}
628
629func (e paletteExec) NextTab() error {
630	return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
631}
632
633func (e paletteExec) PrevTab() error {
634	return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
635}
636
637func (e paletteExec) ToggleFloatingPane() error {
638	return e.d.toggleFloating(e.sess, e.ac)
639}
640
641func (e paletteExec) BackSession() error {
642	if e.effect != nil {
643		return e.d.backSessionForAttachment(e.effect.connectionToken())
644	}
645	e.d.backSession(e.sess, e.ac)
646	return nil
647}
648
649func (e paletteExec) Detach() error {
650	if e.effect != nil {
651		if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
652			return errAttachmentTransition
653		}
654		return nil
655	}
656	e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
657	return nil
658}
659
660func (e paletteExec) EnterVisualMode() error {
661	e.d.enterCopyMode(e.sess, e.ac)
662	return nil
663}
664
665func (e paletteExec) RenameSession() error {
666	e.sess.mu.Lock()
667	currentName := e.sess.name
668	e.sess.mu.Unlock()
669	e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
670		return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
671	})
672	return nil
673}
674
675func (e paletteExec) RenameSessionTo(name string) error {
676	return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
677}
678
679func (e paletteExec) RenameTab() error {
680	tb := e.sess.tabForAttachment(e.ac)
681	if e.ac == nil {
682		tb = e.sess.firstTab()
683	}
684	if tb == nil {
685		return nil
686	}
687	e.sess.mu.Lock()
688	index := 0
689	for i, candidate := range e.sess.tabs {
690		if candidate == tb {
691			index = i
692			break
693		}
694	}
695	currentName := tabDisplayName(tb, index)
696	e.sess.mu.Unlock()
697	e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
698		return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
699	})
700	return nil
701}
702
703func (e paletteExec) RenameTabTo(name string) error {
704	tb := e.sess.tabForAttachment(e.ac)
705	if e.ac == nil {
706		tb = e.sess.firstTab()
707	}
708	if tb == nil {
709		return nil
710	}
711	return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
712}
713
714func (e paletteExec) OpenSessionPicker() error {
715	if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
716		return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
717	}
718	e.d.enterPicker(e.sess, e.ac)
719	return nil
720}
721
722func (e paletteExec) OpenNotifications() error {
723	e.d.enterNotices(e.sess, e.ac)
724	return nil
725}
726
727func (e paletteExec) YankLastNotification() error {
728	entry := e.attachment
729	if entry == nil {
730		entry = e.sess
731	}
732	n, ok := e.d.notices.latest()
733	if !ok {
734		// Reported directly rather than returned: the generic cmd.Run error
735		// path only logs, so a returned error here would never reach the
736		// user as the warn toast this no-op is supposed to produce.
737		e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
738		return nil
739	}
740	e.d.yankNotice(entry, e.ac, n)
741	return nil
742}
743
744func (e paletteExec) JumpRecentSession(rank int) error {
745	if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) {
746		return command.ErrInvalidArguments
747	}
748	entry := e.routeSnapshot.Entries[rank-1]
749	return e.NavigateRecentRoute(ports.RouteNavigationAction{
750		SnapshotGeneration: e.routeSnapshot.Generation,
751		Key:                entry.Key,
752		Generation:         entry.Generation,
753	})
754}
755
756func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error {
757	if e.d.beforeRecentSessionHandoff != nil {
758		e.d.beforeRecentSessionHandoff()
759	}
760	if e.effect != nil {
761		return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
762	}
763	if e.ac == nil || e.attachment == nil {
764		return errAttachmentTransition
765	}
766	return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
767}
768
769func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
770	styleSet := resolveStyles(styles)
771	modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
772	return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
773		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})
774	})
775}

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