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}