1package client
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10 "unicode/utf8"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14)
15
16const uiActionHistory = 64
17
18// UI owns attachment-local action and waiter bookkeeping. It never holds its
19// mutex across terminal access, transport I/O, or predicate evaluation.
20
21type UI struct {
22 state ports.UIState
23 clock ports.Clock
24 handle string
25 mu sync.Mutex
26 changed chan struct{}
27 generation uint64
28 input *terminalInputPump
29 consumer uint64
30 foreground context.Context
31 records map[uint64]ports.UIActionResult
32 order []uint64
33 nextAction uint64
34 pending uint64
35 reservedContext ports.UIContext
36 waits int
37 boundary ports.UIActionResult
38 dispatched map[uint64]bool
39 completion map[uint64]chan struct{}
40 handoff *uiActionHandoff
41 // suppressed is the newest attached context the foreground applied but
42 // did not write because the client picker overlay owned the terminal.
43 suppressed ports.UIContext
44 // overlayAction is the admitted action whose input the client picker
45 // overlay consumed; the supervisor settles it at its event-loop boundary.
46 overlayAction uint64
47}
48
49type uiActionHandoff struct {
50 actionID uint64
51 sourceGeneration uint64
52 destinationGeneration uint64
53 boundary ports.UIActionResult
54}
55
56func NewUI(state ports.UIState, clock ports.Clock) *UI {
57 if clock == nil {
58 clock = systemClock{}
59 }
60 return &UI{state: state, clock: clock, handle: fmt.Sprintf("%x", newClientID()), changed: make(chan struct{}), records: make(map[uint64]ports.UIActionResult), dispatched: make(map[uint64]bool), completion: make(map[uint64]chan struct{})}
61}
62
63func (u *UI) Handle() string { return u.handle }
64
65// WaitForSnapshot waits on the UI owner's broadcast signal rather than
66// consuming the terminal's single coalesced state channel. The predicate is
67// evaluated against the latest owned snapshot and never under the UI mutex.
68func (u *UI) WaitForSnapshot(ctx context.Context, match func(ports.UISnapshot) bool) (ports.UISnapshot, error) {
69 if match == nil {
70 return ports.UISnapshot{}, ports.ErrUIUnavailable
71 }
72 var lastSnapshotErr error
73 for {
74 if err := ctx.Err(); err != nil {
75 if lastSnapshotErr != nil {
76 return ports.UISnapshot{}, errors.Join(err, lastSnapshotErr)
77 }
78 return ports.UISnapshot{}, err
79 }
80 snapshot, err := u.state.Snapshot()
81 if err == nil {
82 lastSnapshotErr = nil
83 if match(snapshot) {
84 return snapshot, nil
85 }
86 } else {
87 lastSnapshotErr = err
88 }
89 u.mu.Lock()
90 changed := u.changed
91 u.mu.Unlock()
92 select {
93 case <-ctx.Done():
94 if lastSnapshotErr != nil {
95 return ports.UISnapshot{}, errors.Join(ctx.Err(), lastSnapshotErr)
96 }
97 return ports.UISnapshot{}, ctx.Err()
98 case <-changed:
99 }
100 }
101}
102
103// ActionComplete returns a closed channel once an accepted action reaches a
104// terminal status. It is an optional lifecycle seam for adapters enforcing
105// attachment-wide action admission after a request timeout.
106func (u *UI) ActionComplete(actionID uint64) <-chan struct{} {
107 if actionID == 0 {
108 return nil
109 }
110 u.mu.Lock()
111 defer u.mu.Unlock()
112 if done, ok := u.completion[actionID]; ok {
113 return done
114 }
115 if record, ok := u.records[actionID]; ok && record.Status != ports.UIActionPending {
116 done := make(chan struct{})
117 close(done)
118 return done
119 }
120 return nil
121}
122
123// publishPresentation publishes one unattached presentation of this run. It
124// writes a cleaned context: the run's stable handle and the presentation only,
125// with no session identity and no committed output boundary, and it never
126// changes the internal generation counter. A caller therefore can never fence an
127// action against a fabricated generation, and a capture in Picker or Connecting
128// never leaks session metadata.
129//
130// Attached is deliberately not publishable here. Only the attachment foreground
131// may publish the attached presentation, and only once its first frame was
132// written, flushed, and committed, carrying the real action generation. A closed
133// UI observation channel reports ErrUIUnavailable instead of inventing state.
134func (u *UI) publishPresentation(status ports.UIPresentationStatus) error {
135 if status != ports.UIStatusPicker && status != ports.UIStatusConnecting {
136 return &ports.UIError{Code: ports.UIErrInvalidRequest}
137 }
138 publication, ok := u.state.(ports.UIOutputTransaction)
139 if !ok {
140 return ports.ErrUIUnavailable
141 }
142 return publication.PublishContext(presentationContext(u.handle, status, ports.UIContext{}))
143}
144
145// PublishPresentation publishes one unattached presentation of this run through
146// the UI owner. It is the composition seam for the presentations the supervisor
147// does not itself drive — the initial Picker a driver publishes before any
148// broker attempt — and it keeps the private shape rule in one place. Attached is
149// refused: only the admitted attachment foreground may publish it.
150func (u *UI) PublishPresentation(status ports.UIPresentationStatus) error {
151 if u == nil {
152 return ports.ErrUIUnavailable
153 }
154 return u.publishPresentation(status)
155}
156
157// presentationContext is the single shape rule for a published UI context. Only
158// Attached may carry the session identity, the committed output boundary, and
159// the real actionable generation; Picker and Connecting carry the run's stable
160// handle and their status alone, with every session field zeroed. It is applied
161// by every publisher, so a capture can never present session metadata as an
162// attachment and an unattached publication can never fence an action against a
163// generation the attachment never committed.
164func presentationContext(handle string, status ports.UIPresentationStatus, identity ports.UIContext) ports.UIContext {
165 if status == ports.UIStatusAttached {
166 identity.AttachmentHandle = handle
167 return identity
168 }
169 return ports.UIContext{AttachmentHandle: handle, Status: status}
170}
171
172// Observe relays the sink's single coalesced signal to bounded concurrent
173// waiters. Its caller owns this worker's lifetime.
174func (u *UI) Observe(ctx context.Context) {
175 defer func() {
176 u.mu.Lock()
177 if u.pending != 0 {
178 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
179 }
180 u.mu.Unlock()
181 }()
182 for {
183 select {
184 case <-ctx.Done():
185 return
186 case <-u.state.Changes():
187 u.mu.Lock()
188 u.signalLocked()
189 u.mu.Unlock()
190 }
191 }
192}
193
194func (u *UI) signalLocked() { close(u.changed); u.changed = make(chan struct{}) }
195
196func (u *UI) Capture(attachment string) (ports.UISnapshot, error) {
197 if attachment != u.handle {
198 return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
199 }
200 return u.state.Snapshot()
201}
202
203func uiTimeout(timeout time.Duration) (time.Duration, error) {
204 if timeout == 0 {
205 return 5 * time.Second, nil
206 }
207 if timeout < time.Millisecond || timeout > 30*time.Second {
208 return 0, &ports.UIError{Code: ports.UIErrInvalidRequest}
209 }
210 return timeout, nil
211}
212
213func validateUIExpect(expect ports.UIExpect) bool {
214 if expect.TextContains == nil && expect.Session == nil && expect.Focus == nil && expect.Status == nil {
215 return false
216 }
217 if expect.TextContains != nil && (len(*expect.TextContains) > 4096 || !utf8.ValidString(*expect.TextContains)) {
218 return false
219 }
220 if expect.Session != nil && expect.Session.Validate() != nil {
221 return false
222 }
223 if expect.Focus != nil && (domain.ValidateTabStableID(expect.Focus.TabID) != nil || domain.ValidatePaneStableID(expect.Focus.PaneID) != nil) {
224 return false
225 }
226 if expect.Status != nil && !expect.Status.Valid() {
227 return false
228 }
229 return true
230}
231
232func uiMatches(snapshot ports.UISnapshot, expect ports.UIExpect) bool {
233 if expect.Session != nil && snapshot.Context.Route.Target != *expect.Session {
234 return false
235 }
236 if expect.Focus != nil && (snapshot.Context.TabID != expect.Focus.TabID || snapshot.Context.FocusedPaneID != expect.Focus.PaneID) {
237 return false
238 }
239 if expect.Status != nil && snapshot.Context.Status != *expect.Status {
240 return false
241 }
242 if expect.TextContains != nil && !strings.Contains(uiSnapshotText(snapshot), *expect.TextContains) {
243 return false
244 }
245 return true
246}
247
248func uiSnapshotText(snapshot ports.UISnapshot) string {
249 var text strings.Builder
250 for row := 0; row < snapshot.Rows; row++ {
251 if row > 0 {
252 text.WriteByte('\n')
253 }
254 for col := 0; col < snapshot.Columns; col++ {
255 index := row*snapshot.Columns + col
256 if index >= len(snapshot.Cells) {
257 text.WriteByte(' ')
258 continue
259 }
260 cell := snapshot.Cells[index]
261 if cell.Continuation {
262 continue
263 }
264 if cell.Text == "" {
265 text.WriteByte(' ')
266 } else {
267 text.WriteString(cell.Text)
268 }
269 }
270 }
271 return text.String()
272}
273
274func (u *UI) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) {
275 timeout, err := uiTimeout(request.Timeout)
276 if err != nil || !validateUIExpect(request.Expect) {
277 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
278 }
279 if request.Attachment != u.handle {
280 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
281 }
282 u.mu.Lock()
283 if u.waits >= 4 {
284 u.mu.Unlock()
285 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrBusy}
286 }
287 u.waits++
288 u.mu.Unlock()
289 defer func() { u.mu.Lock(); u.waits--; u.mu.Unlock() }()
290 timer := u.clock.NewTimer(timeout)
291 defer timer.Stop()
292 for {
293 if err := ctx.Err(); err != nil {
294 return ports.UIWaitResult{}, err
295 }
296 u.mu.Lock()
297 changed := u.changed
298 action, exists := u.records[request.AfterAction]
299 generation := u.generation
300 u.mu.Unlock()
301 if request.AfterAction != 0 {
302 if !exists {
303 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrActionExpired, ActionID: request.AfterAction}
304 }
305 if action.Status != ports.UIActionPending && action.Status != ports.UIActionProcessed {
306 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrorCode(action.Status), Accepted: action.Accepted, ActionID: action.ActionID}
307 }
308 if action.Status == ports.UIActionProcessed && action.Context.Generation != generation {
309 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrOutcomeUnknown, Accepted: true, ActionID: action.ActionID}
310 }
311 }
312 snapshot, snapshotErr := u.Capture(request.Attachment)
313 eligible := request.AfterAction == 0 || action.Status == ports.UIActionProcessed && snapshot.Revision >= action.Revision && snapshot.Context.Generation == action.Context.Generation
314 if snapshotErr == nil && eligible && uiMatches(snapshot, request.Expect) {
315 if request.AfterAction != 0 {
316 u.mu.Lock()
317 current := u.records[request.AfterAction]
318 unchanged := current == action && u.generation == generation
319 u.mu.Unlock()
320 if !unchanged {
321 continue
322 }
323 }
324 return ports.UIWaitResult{ActionID: request.AfterAction, ActionStatus: action.Status, Revision: snapshot.Revision, Context: snapshot.Context}, nil
325 }
326 select {
327 case <-ctx.Done():
328 return ports.UIWaitResult{}, ctx.Err()
329 case <-timer.C():
330 return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrTimeout, Accepted: action.Accepted, ActionID: request.AfterAction}
331 case <-changed:
332 }
333 }
334}