picker_controller.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"errors"
6	"slices"
7	"sync"
8	"time"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/domain/terminalcap"
12	"github.com/bnema/vev/internal/ports"
13	"github.com/bnema/vev/internal/protocol"
14	"github.com/bnema/vev/internal/protocol/catalogue"
15	"github.com/bnema/vev/internal/usecase/colorprofile"
16	pickerusecase "github.com/bnema/vev/internal/usecase/picker"
17	"github.com/bnema/vev/internal/usecase/ui"
18)
19
20// Client-owned picker controller.
21//
22// This file is the narrow seam between the autonomous supervisor and the
23// client-owned catalogue. The supervisor owns raw mode, the single terminal
24// input lifetime, and the broker service; the controller owns the catalogue
25// projection, the presentation model (reusing the picker package's cursor,
26// search, sort, and rendering machinery), and the input decisions.
27//
28// Two invariants make the picker autonomous:
29//
30//   - Zero session bytes. The controller has no session, stream, or PTY
31//     reference. A consumed terminal read is decoded into picker events and
32//     applied to the model; nothing is ever written toward a daemon. A
33//     selection resolves to a ports.BrokerOpenStreamRequest value and stops
34//     there.
35//   - No serving-daemon catalogue. Rows come from broker snapshots through
36//     pickerCatalogue, so navigation needs no serving attachment.
37//
38// input ownership reuses the existing pickerConsumer, so the supervisor's one
39// terminal input lifetime feeds exactly one owner and the picker takes
40// precedence: while the controller owns input every decoded batch is consumed
41// and there is no fallback to a session pipeline.
42
43// pickerHost is the supervisor's view of the client-owned picker. The
44// supervisor applies each broker publication and hands it every terminal read
45// from its single input lifetime; the picker decides ownership.
46//
47// The commit seam is what lets the supervisor turn a user
48// commit into one exact broker stream without ever reading the presentation
49// model itself: the picker records the decision and the key it committed
50// atomically, the supervisor wakes on OpsReady, and ResolveCommit revalidates
51// exactly that key. The picker never opens a stream, and the supervisor never
52// reconstructs a selection from a display label.
53type pickerHost interface {
54	pickerInputConsumer
55	// ApplySnapshot folds one broker publication into the catalogue and
56	// refreshes the presentation immediately.
57	ApplySnapshot(ports.BrokerSnapshot)
58	// TakeOp returns and clears the accumulated presentation decision together
59	// with the catalogue key captured with a commit decision.
60	TakeOp() (pickerOp, string)
61	// ResolveCommit revalidates exactly one committed catalogue key into the
62	// exact broker stream request and tab the user committed.
63	ResolveCommit(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
64	// SetOwnsInput releases or re-acquires picker input ownership at an attach
65	// boundary, so exactly one owner consumes the shared terminal reader.
66	SetOwnsInput(bool)
67	// OpsReady wakes the supervisor when a presentation decision was recorded.
68	// It is a capacity-one coalescing signal, never closed while the picker
69	// lives, so a driver that ignores it only misses a wakeup, never blocks.
70	OpsReady() <-chan struct{}
71}
72
73// pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
74// returns true when the picker owned and consumed the read. It returns false for
75// bytes the picker declined, which are then dropped: the supervisor keeps no
76// ordinary terminal handling alongside the picker, so a declined read has no
77// second owner to hand it to.
78type pickerInputConsumer interface {
79	ConsumeTerminalRead(data []byte) bool
80}
81
82const (
83	// pickerGeneration identifies the single client-owned picker generation.
84	// It never changes while the controller lives; the presentation epoch is
85	// the broker epoch, not this value.
86	pickerGeneration = uint64(1)
87	// pickerNoticeLifetime bounds how long one catalogue notice is displayed.
88	pickerNoticeLifetime = 4 * time.Second
89	// pickerNoticeVisible is how many notices stack at once; later ones wait.
90	pickerNoticeVisible = 3
91)
92
93// pickerController owns one client-owned picker: catalogue, model, renderer,
94// bounded notices, and input ownership. It is safe for concurrent use, so the
95// supervisor's input lifetime and its run path may both reach it.
96type pickerController struct {
97	clock     ports.Clock
98	catalogue *pickerCatalogue
99
100	mu       sync.Mutex
101	loop     *pickerLoop
102	consumer pickerConsumer
103	renderer *pickerRenderer
104	notices  *ui.ToastQueue[ui.Toast]
105	// noticeBounds are the toast boxes drawn last. The client cannot restore
106	// the session under them, so boxes that go away are blanked.
107	noticeBounds []domain.Rect
108	interaction  uint64
109	// sort is this client's picker ordering. It survives every picker
110	// reopen for the lifetime of the client process.
111	sort      pickerusecase.SortMode
112	ownsInput bool
113	opsReady  chan struct{}
114	// pending is the decision accumulated since the last TakeOp.
115	pending pickerDecision
116	// hostHealth is the last remote host state seen, so each host transition
117	// is judged once by domain.RemoteHealthNotice.
118	hostHealth map[string]domain.RemoteHealth
119	preview    pickerusecase.Preview
120	// flushStop cancels the armed lone-escape flush. It is non-nil exactly
121	// while one flush is armed for the currently withheld prefix.
122	flushStop chan struct{}
123}
124
125// newPickerController builds a picker over an empty catalogue. The picker owns
126// input from the start: the controller presents it for the whole picker presentation and
127// has no session pipeline to defer to.
128func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController {
129	if supervisorNil(clock) {
130		clock = systemClock{}
131	}
132	controller := &pickerController{
133		clock:     clock,
134		catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
135		renderer:  newPickerRenderer(colorprofile.Profile(color)),
136		sort:      defaultPickerSort(),
137		notices:   ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
138		ownsInput: true,
139		opsReady:  make(chan struct{}, 1),
140	}
141	controller.consumer.setOwned(pickerGeneration, pickerGeneration)
142	return controller
143}
144
145// Catalogue exposes the projection for a driver that needs to resolve a
146// selection or inspect the applied revision.
147func (p *pickerController) Catalogue() *pickerCatalogue {
148	if p == nil {
149		return nil
150	}
151	return p.catalogue
152}
153
154// SetOwnsInput toggles input ownership. It is the future attach slice's release
155// boundary; the controller keeps it owned for the whole run.
156func (p *pickerController) SetOwnsInput(owns bool) {
157	if p == nil {
158		return
159	}
160	p.mu.Lock()
161	defer p.mu.Unlock()
162	if owns == p.ownsInput {
163		return
164	}
165	p.ownsInput = owns
166	if owns {
167		p.consumer.setOwned(pickerGeneration, pickerGeneration)
168		return
169	}
170	p.consumer.clear()
171}
172
173// ApplySnapshot folds one broker publication into the catalogue and rebuilds
174// the presentation synchronously, so the next Render reflects it immediately.
175func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
176	if p == nil || p.catalogue == nil {
177		return
178	}
179	if !p.catalogue.Apply(snapshot) {
180		// A publication that fails the broker snapshot contract is rejected as
181		// a whole (the previous projection is kept) and surfaced as a bounded
182		// notice instead of silently vanishing. A merely stale publication
183		// validates and is ignored without a notice.
184		if snapshot.Validate() != nil {
185			p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"})
186		}
187		return
188	}
189	p.mu.Lock()
190	defer p.mu.Unlock()
191	p.rebuildLocked()
192	p.offerDiagnosticsLocked()
193}
194
195// ConsumeTerminalRead decodes one terminal read while the picker owns input,
196// applies it to the model, and records the presentation decision (with any
197// committed key) atomically under the controller lock. It reports whether the
198// read was consumed: a false result means the supervisor keeps its ordinary
199// handling. It never writes a session.
200func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
201	if p == nil {
202		return false
203	}
204	return p.handleRead(data)
205}
206
207// pickerDecision accumulates the presentation decisions of one or more reads
208// together with the row keys each decision selected when it was recorded, so
209// a key and its decision can never disagree.
210type pickerDecision struct {
211	op        pickerOp
212	commitKey string
213	killKey   string
214}
215
216// record merges op, capturing the cursor row for a commit or a kill.
217func (d *pickerDecision) record(op pickerOp, loop *pickerLoop) {
218	if op.commit {
219		d.commitKey = loop.selectionKey(protocol.PickerActionNavigate)
220	}
221	if op.kill {
222		d.killKey = loop.selectionKey(protocol.PickerActionKill)
223	}
224	d.op = mergePickerOps(d.op, op)
225}
226
227// actionable reports whether the decision asks the driver to act.
228func (d pickerDecision) actionable() bool { return d.op.commit || d.op.close || d.op.kill }
229
230// key is the row the decision acts on. A commit wins over a kill in the same
231// read because it leaves the picker anyway.
232func (d pickerDecision) key() string {
233	if d.op.commit {
234		return d.commitKey
235	}
236	return d.killKey
237}
238
239// recordOpLocked accumulates one read's presentation decision and wakes the
240// driver once when it became actionable. Callers hold p.mu.
241func (p *pickerController) recordOpLocked(op pickerOp) {
242	p.pending.record(op, p.loop)
243	if p.pending.actionable() {
244		p.wakeLocked()
245	}
246}
247
248// wakeLocked coalesces exactly one wakeup: a driver that has not yet run
249// TakeOp re-reads the accumulated decision, so a dropped signal is harmless.
250func (p *pickerController) wakeLocked() {
251	select {
252	case p.opsReady <- struct{}{}:
253	default:
254	}
255}
256
257// applyEventsLocked applies one decoded batch to the open loop and records
258// its decision. Callers hold p.mu.
259func (p *pickerController) applyEventsLocked(events []pickerEvent) {
260	op, changed := p.loop.apply(events)
261	p.recordOpLocked(op)
262	if changed {
263		p.wakeLocked()
264	}
265}
266
267// OpsReady wakes the supervisor when a commit or close was recorded. It is a
268// capacity-one coalescing signal; TakeOp remains the authority on what was
269// decided, so a driver never infers the decision from the wakeup alone.
270func (p *pickerController) OpsReady() <-chan struct{} {
271	if p == nil {
272		return nil
273	}
274	return p.opsReady
275}
276
277// TakeOp returns and clears the accumulated presentation decision since the
278// last call, together with the catalogue key of the row the commit decision
279// selected at the moment it was recorded. Resolving that key with ResolveKey
280// attaches exactly the committed row: TakeOp cannot observe a cursor that a
281// concurrent ApplySnapshot moved afterwards. The key is empty when the
282// decision carries no commit or no committable row.
283func (p *pickerController) TakeOp() (pickerOp, string) {
284	if p == nil {
285		return pickerOp{}, ""
286	}
287	p.mu.Lock()
288	defer p.mu.Unlock()
289	decision := p.pending
290	p.pending = pickerDecision{}
291	return decision.op, decision.key()
292}
293
294// FlushPending resolves a withheld escape or UTF-8 prefix once its
295// disambiguation window expired. The bound belongs to the driver; the picker
296// only decides what the prefix means.
297func (p *pickerController) FlushPending() {
298	if p == nil {
299		return
300	}
301	p.mu.Lock()
302	defer p.mu.Unlock()
303	if !p.ownsInput {
304		return
305	}
306	outcome, consumed := p.consumer.flushPending()
307	if !consumed || p.loop == nil {
308		return
309	}
310	if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
311		return
312	}
313	p.applyEventsLocked(outcome.events)
314}
315
316// armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
317// disambiguation window on the controller clock, so a lone Escape keypress
318// resolves without waiting for another key. A later read disarms it first.
319// Callers hold p.mu.
320func (p *pickerController) armFlushLocked() {
321	if p.flushStop != nil || !p.consumer.hasPending() {
322		return
323	}
324	timer := p.clock.NewTimer(pickerEscapeDeadline)
325	if supervisorNil(timer) {
326		return
327	}
328	stop := make(chan struct{})
329	p.flushStop = stop
330	go func() {
331		select {
332		case <-timer.C():
333		case <-stop:
334			stopSupervisorTimer(timer)
335			return
336		}
337		p.mu.Lock()
338		if p.flushStop != stop {
339			p.mu.Unlock()
340			return
341		}
342		p.flushStop = nil
343		p.mu.Unlock()
344		p.FlushPending()
345	}()
346}
347
348// disarmFlushLocked cancels an armed flush: the next read resolves the
349// withheld prefix itself. Callers hold p.mu.
350func (p *pickerController) disarmFlushLocked() {
351	if p.flushStop == nil {
352		return
353	}
354	close(p.flushStop)
355	p.flushStop = nil
356}
357
358// invalidatePresentation makes the next Render redraw the whole box, because
359// another owner wrote the terminal since the last picker frame.
360func (p *pickerController) invalidatePresentation() {
361	if p == nil {
362		return
363	}
364	p.mu.Lock()
365	p.renderer.invalidate()
366	// Another owner repainted the screen: the old toast cells are gone.
367	p.noticeBounds = nil
368	p.mu.Unlock()
369}
370
371// OwnsInput reports whether the picker currently owns terminal input.
372func (p *pickerController) OwnsInput() bool {
373	if p == nil {
374		return false
375	}
376	p.mu.Lock()
377	defer p.mu.Unlock()
378	return p.ownsInput
379}
380
381// Render composes the current picker modal into terminal bytes for one display
382// size. It is a pure read of the current model: it changes no state and writes
383// nothing.
384func (p *pickerController) Render(size domain.Size) []byte {
385	if p == nil {
386		return nil
387	}
388	p.mu.Lock()
389	defer p.mu.Unlock()
390	if p.loop == nil {
391		return nil
392	}
393	// Blank gone notices before the box is drawn, so a blank that overlaps
394	// the box never erases cells the picker renderer believes are on screen.
395	// A blank may also cover part of the box, so the box is then redrawn whole.
396	_, blanks := p.noticeStackLocked(size)
397	if len(blanks) != 0 {
398		p.renderer.invalidate()
399	}
400	return append(blanks, p.renderer.render(p.loop, size, p.preview)...)
401}
402
403// SetPreview displays one preview frame in the given state. A frame is shown
404// only in the fresh and stale states; stale dims it. The other states show a
405// short dim label instead, so the preview pane is never silently blank.
406func (p *pickerController) SetPreview(preview protocol.RemotePreview, state previewState) {
407	if p == nil {
408		return
409	}
410	view := previewView(preview, state)
411	p.mu.Lock()
412	p.preview = view
413	p.mu.Unlock()
414}
415
416// PreviewRequest derives preview authority from the exact catalogue ref behind
417// the selected row. Only live exact sessions are previewable; a tab row
418// previews its own tab and a session row the active one. Create rows and
419// stopped sessions are deliberately refused.
420func (p *pickerController) PreviewRequest(size domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
421	refuse := func() (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
422		return ports.BrokerPreviewRoute{}, protocol.RemotePreviewRequest{}, false
423	}
424	key, ok := p.CursorKey()
425	if !ok || p.catalogue == nil {
426		return refuse()
427	}
428	ref, ok := p.catalogue.Ref(key)
429	if !ok || ref.kind != pickerSelectionExact {
430		return refuse()
431	}
432	// Resolving with a provisional stream reuses every refusal the commit path
433	// applies (epoch, replaced registration, incompatibility, gone session);
434	// the broker allocates the real observation stream itself.
435	resolved, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: 1})
436	if err != nil {
437		return refuse()
438	}
439	p.catalogue.mu.Lock()
440	authority := p.catalogue.authorityForRefLocked(ref)
441	p.catalogue.mu.Unlock()
442	if !authority.found {
443		return refuse()
444	}
445	session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
446	if !ok || session.State != catalogue.RemoteCatalogSessionUp {
447		return refuse()
448	}
449	previewTab := domain.TabStableID(session.ActiveTabID)
450	if ref.tab.present {
451		previewTab = ref.tab.id
452	}
453	if previewTab == "" {
454		return refuse()
455	}
456	route := ports.BrokerPreviewRoute{Local: resolved.Local, Policy: resolved.Policy}
457	endpoint := ports.BrokerPreviewLocalEndpoint
458	if !resolved.Local {
459		route.Endpoint, route.Registration = resolved.Endpoint, resolved.Registration
460		endpoint = resolved.Endpoint
461	}
462	viewport := pickerPreviewSize(size)
463	target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab}
464	if target.DisplayOrigin == "" {
465		target.DisplayOrigin = pickerOriginLabel(authority.observation)
466	}
467	preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
468	if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil {
469		return refuse()
470	}
471	return route, preview, true
472}
473
474func (p *pickerController) ClearPreview() {
475	if p == nil {
476		return
477	}
478	p.mu.Lock()
479	p.preview = pickerusecase.Preview{}
480	p.mu.Unlock()
481}
482
483// RenderNotice composes the live notice stack as client-local toasts: the
484// newest at the top-right corner, older ones below it, and any extra notices
485// waiting for a free slot. Boxes drawn by the previous call that are gone now
486// are blanked in the same payload. It returns nil when nothing changes on
487// screen.
488func (p *pickerController) RenderNotice(size domain.Size) []byte {
489	if p == nil {
490		return nil
491	}
492	p.mu.Lock()
493	defer p.mu.Unlock()
494	toasts, blanks := p.noticeStackLocked(size)
495	if len(blanks) != 0 {
496		// A blank may cover picker box cells drawn by the previous Render.
497		p.renderer.invalidate()
498	}
499	buffer := bytes.NewBuffer(blanks)
500	for i, rect := range p.noticeBounds {
501		_ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile)))
502	}
503	if buffer.Len() == 0 {
504		return nil
505	}
506	return buffer.Bytes()
507}
508
509// noticeStackLocked lays out the live notices for size and returns them with
510// the bytes that blank boxes drawn before but no longer part of the stack.
511// It records the new layout in noticeBounds.
512func (p *pickerController) noticeStackLocked(size domain.Size) ([]ui.Toast, []byte) {
513	active := p.notices.Visible(p.clock.Now())
514	toasts := make([]ui.Toast, len(active))
515	for i, t := range active {
516		toasts[i] = t.Value
517	}
518	bounds := ui.ToastStackBounds(size, toasts)
519	var buffer bytes.Buffer
520	for _, old := range p.noticeBounds {
521		if slices.Contains(bounds, old) {
522			continue
523		}
524		// After a shrink the old box may overhang the screen; writing past the
525		// edge would wrap onto unrelated cells.
526		if gone := clipRect(old, size); gone.Width > 0 && gone.Height > 0 {
527			_ = writeClientToast(&buffer, gone, blankToastLines(gone))
528		}
529	}
530	p.noticeBounds = bounds
531	return toasts[:len(bounds)], buffer.Bytes()
532}
533
534// clipRect is the part of r inside a size-sized screen.
535func clipRect(r domain.Rect, size domain.Size) domain.Rect {
536	x0, y0 := max(r.X, 0), max(r.Y, 0)
537	x1, y1 := min(r.X+r.Width, size.Cols), min(r.Y+r.Height, size.Rows)
538	if x1 <= x0 || y1 <= y0 {
539		return domain.Rect{}
540	}
541	return domain.Rect{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0}
542}
543
544// noticeDeadline is when the notice stack next changes on its own.
545func (p *pickerController) noticeDeadline() (time.Time, bool) {
546	if p == nil {
547		return time.Time{}, false
548	}
549	p.mu.Lock()
550	defer p.mu.Unlock()
551	return p.notices.NextDeadline()
552}
553
554// pickerState is a consistent read of the controller's interactive state.
555type pickerState struct {
556	hasModel     bool
557	query        string
558	searchActive bool
559	notices      []string
560}
561
562// state reads the interactive state under one lock.
563func (p *pickerController) state() pickerState {
564	p.mu.Lock()
565	defer p.mu.Unlock()
566	var state pickerState
567	if p.loop != nil && p.loop.model != nil {
568		state.hasModel = true
569		state.query = p.loop.model.Query()
570		state.searchActive = p.loop.model.SearchActive()
571	}
572	for _, toast := range p.notices.Visible(p.clock.Now()) {
573		state.notices = append(state.notices, toast.Value.Message)
574	}
575	return state
576}
577
578// CursorKey reports the opaque key of the row under the cursor, or false when
579// nothing qualifies. It is the selection displayed now; a commit decision is
580// captured with TakeOp instead so a concurrent publication cannot move it.
581func (p *pickerController) CursorKey() (string, bool) {
582	if p == nil {
583		return "", false
584	}
585	p.mu.Lock()
586	defer p.mu.Unlock()
587	key := p.loop.selectionKey(protocol.PickerActionNavigate)
588	return key, key != ""
589}
590
591// Resolve revalidates the row displayed now and returns the exact broker
592// stream request. A row the picker shows but does not admit an action still
593// resolves, so committing an inspectable row yields a typed catalogue refusal
594// (stale, incompatible, unavailable) instead of a generic no-selection. A
595// refusal is bounded and is also surfaced as a notice.
596//
597// Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
598// can move the cursor between the caller's commit decision and this call. A
599// driver that is committing a specific row must capture that row's key with
600// the decision (TakeOp) and resolve it with ResolveCommit instead.
601func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
602	if p == nil {
603		return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
604	}
605	key, ok := p.displayedKey()
606	if !ok {
607		err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
608		p.Notify(pickerRefusalNotice(err))
609		return ports.BrokerOpenStreamRequest{}, err
610	}
611	request, _, err := p.ResolveCommit(key, base)
612	return request, err
613}
614
615// ResolveCommit is the single commit path from a published row to an
616// attachment target. It revalidates exactly the supplied catalogue key
617// against the latest applied snapshot, in one catalogue transaction, and
618// returns the exact broker stream request plus the tab the row names. The key
619// captured atomically with the commit decision (see TakeOp) resolves the row
620// the user committed even if the cursor has since moved. A refusal is bounded
621// and is also surfaced as a notice.
622func (p *pickerController) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
623	if p == nil || p.catalogue == nil {
624		err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
625		if p != nil {
626			p.Notify(pickerRefusalNotice(err))
627		}
628		return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
629	}
630	request, tab, err := p.catalogue.ResolveTarget(key, base)
631	if err != nil {
632		p.Notify(pickerRefusalNotice(err))
633		return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
634	}
635	return request, tab, nil
636}
637
638// ResolveKill revalidates one kill key; a refusal is surfaced as a notice.
639func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
640	if p == nil || p.catalogue == nil {
641		return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
642	}
643	target, err := p.catalogue.ResolveKill(key)
644	if err != nil {
645		p.Notify(pickerRefusalNotice(err))
646		return pickerKillTarget{}, err
647	}
648	return target, nil
649}
650
651// SetCurrent names the attachment the picker is about to be presented over
652// (or none) and restarts the presentation, so a fresh picker opens with the
653// cursor on that session's tab and no leftover search, exactly as each daemon
654// interaction did on main.
655func (p *pickerController) SetCurrent(current pickerCurrent) {
656	if p == nil || p.catalogue == nil {
657		return
658	}
659	p.catalogue.SetCurrent(current)
660	p.mu.Lock()
661	defer p.mu.Unlock()
662	if p.catalogue.Epoch() == 0 {
663		return
664	}
665	p.rememberSortLocked()
666	p.loop = nil
667	p.rebuildLocked()
668	p.renderer.invalidate()
669	p.noticeBounds = nil
670}
671
672// rememberSortLocked keeps the sort the user chose in the closing
673// presentation for the next one.
674func (p *pickerController) rememberSortLocked() {
675	if sort, ok := p.loop.sortMode(); ok {
676		p.sort = sort
677	}
678}
679
680// pickerRefusalNotice is the notice for one resolution refusal.
681func pickerRefusalNotice(err error) domain.Notification {
682	text := err.Error()
683	var typed pickerCatalogueError
684	if errors.As(err, &typed) {
685		text = typed.noticeText()
686	}
687	return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text}
688}
689
690// displayedKey names the row under the cursor, whether or not it admits an
691// action, and maps its presentation key back to the source identity.
692func (p *pickerController) displayedKey() (string, bool) {
693	p.mu.Lock()
694	defer p.mu.Unlock()
695	return p.loop.displayedKey()
696}
697
698func (p *pickerController) handleRead(data []byte) bool {
699	p.mu.Lock()
700	defer p.mu.Unlock()
701	if !p.ownsInput {
702		return false
703	}
704	if len(data) != 0 {
705		data = append([]byte(nil), data...)
706	}
707	p.disarmFlushLocked()
708	outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
709	if !consumed {
710		return false
711	}
712	defer p.armFlushLocked()
713	if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
714		return true
715	}
716	if p.loop == nil {
717		// Before a catalogue exists only an explicit interrupt is actionable.
718		// Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
719		for _, event := range outcome.events {
720			if event.kind == pickerEventKey && event.key == "Ctrl+C" {
721				p.recordOpLocked(pickerOp{close: true, exit: true})
722			}
723		}
724		return true
725	}
726	p.applyEventsLocked(outcome.events)
727	return true
728}
729
730func (p *pickerController) rebuildLocked() {
731	recent, grouped := p.catalogue.projections()
732	revision := uint64(p.catalogue.Revision())
733	snapshot := func() protocol.PickerSnapshot {
734		return protocol.PickerSnapshot{
735			InteractionID:  p.interaction,
736			SourceID:       pickerCatalogueSourceID,
737			SourceRevision: revision,
738			Lines:          grouped.Lines,
739			Cursor:         grouped.Cursor,
740			Recent:         recent,
741			Grouped:        grouped,
742		}
743	}
744	if p.loop == nil {
745		p.interaction++
746		p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, p.sort)
747		return
748	}
749	p.loop.replaceLines(snapshot())
750}
751
752// offerDiagnosticsLocked notices each remote host transition that
753// domain.RemoteHealthTransition deems worth one toast: a new failure or a
754// recovery. Ordinary observation progress stays on the rows.
755func (p *pickerController) offerDiagnosticsLocked() {
756	current := make(map[string]domain.RemoteHealth)
757	for _, host := range p.catalogue.remoteHealth() {
758		prev, seen := p.hostHealth[host.key]
759		next, event := domain.RemoteHealthTransition(prev, seen, host.health)
760		current[host.key] = next
761		if event != domain.RemoteHealthSilent {
762			p.notifyLocked(pickerHostHealthNotice(host, event))
763		}
764	}
765	p.hostHealth = current
766}
767
768// Notify shows one client-local notice. Notices about the same subject
769// (domain.Notification.Subject) replace each other.
770func (p *pickerController) Notify(n domain.Notification) {
771	if p == nil {
772		return
773	}
774	p.mu.Lock()
775	p.notifyLocked(n)
776	p.mu.Unlock()
777}
778
779func (p *pickerController) notifyLocked(n domain.Notification) {
780	if n.Message == "" {
781		return
782	}
783	p.notices.Push(p.clock.Now(), ui.QueuedToast[ui.Toast]{ID: n.Subject(), Duration: pickerNoticeLifetime, Value: ui.Toast{Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight}})
784}
785
786// mergePickerOps accumulates two presentation decisions from one read. Close
787// wins because it retires the interaction; commit and kill both remain.
788func mergePickerOps(a, b pickerOp) pickerOp {
789	return pickerOp{
790		commit: a.commit || b.commit,
791		kill:   a.kill || b.kill,
792		close:  a.close || b.close,
793		exit:   a.exit || b.exit,
794	}
795}

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