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