1// Package uiterm provides a deterministic VT-backed client terminal for UI
2// automation and an observation sink for interactive terminals.
3package uiterm
4
5import (
6 "context"
7 "errors"
8 "io"
9 "sync"
10
11 vt "github.com/bnema/vev-vt"
12 core "github.com/bnema/vev-vt/core"
13 "github.com/bnema/vev/internal/adapters/term"
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"
16)
17
18const (
19 MaxColumns = 512
20 MaxRows = 256
21 replyQueueDepth = 64
22)
23
24var (
25 _ ports.Terminal = (*Terminal)(nil)
26 _ ports.UIState = (*Terminal)(nil)
27 _ ports.UIOutputTransaction = (*Terminal)(nil)
28 _ ports.UIObservationSink = (*Terminal)(nil)
29)
30
31type Terminal struct {
32 mu sync.Mutex
33
34 screen *vt.Screen
35 mirror bool
36 geometry domain.Geometry
37 context ports.UIContext
38 published vt.ScreenSnapshot // committed screen of the latest revision
39 publishedGeom domain.Geometry
40 publishedCtx ports.UIContext
41 latest ports.UISnapshot // conversion cache, built on the first read
42 revision uint64
43 available bool
44 captureTooLarge bool
45 dirty bool
46 closed bool
47 txDepth int
48 txFailed bool
49
50 changes chan struct{}
51 replies chan []byte
52 inR *io.PipeReader
53 inW *io.PipeWriter
54 cancel context.CancelFunc
55 done chan struct{}
56}
57
58func New(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) {
59 return newTerminal(ctx, geometry, attachmentHandle, true, false)
60}
61
62// NewMirror creates a VT mirror for an existing physical terminal. Physical
63// terminals remain the sole authority for terminal query responses, so the
64// mirror deliberately leaves Screen.OnResponse unset. Unlike a headless
65// terminal, an interactive mirror may represent an oversized viewport; its
66// captures then report capture_too_large while the physical terminal keeps
67// operating normally.
68func NewMirror(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) {
69 return newTerminal(ctx, geometry, attachmentHandle, false, true)
70}
71
72func newTerminal(ctx context.Context, geometry domain.Geometry, attachmentHandle string, deterministicReplies, allowOversized bool) (*Terminal, error) {
73 geometry = geometry.NormalizePixels()
74 if geometry.Cols <= 0 || geometry.Rows <= 0 || (!allowOversized && (geometry.Cols > MaxColumns || geometry.Rows > MaxRows)) {
75 return nil, errors.New("uiterm: geometry outside supported bounds")
76 }
77 oversized := geometry.Cols > MaxColumns || geometry.Rows > MaxRows
78 var screen *vt.Screen
79 if !oversized {
80 screen = vt.NewScreen(geometry.Cols, geometry.Rows)
81 }
82 ctx, cancel := context.WithCancel(ctx)
83 inR, inW := io.Pipe()
84 t := &Terminal{
85 screen: screen,
86 mirror: allowOversized,
87 geometry: geometry,
88 context: ports.UIContext{AttachmentHandle: attachmentHandle},
89 available: true,
90 captureTooLarge: oversized,
91 changes: make(chan struct{}, 1),
92 replies: make(chan []byte, replyQueueDepth),
93 inR: inR,
94 inW: inW,
95 cancel: cancel,
96 done: make(chan struct{}),
97 }
98 if !deterministicReplies || screen == nil {
99 close(t.done)
100 return t, nil
101 }
102 t.screen.OnResponse = func(response []byte) {
103 copyResponse := append([]byte(nil), response...)
104 select {
105 case t.replies <- copyResponse:
106 default:
107 t.available = false
108 t.signalChange()
109 }
110 }
111 go t.runReplies(ctx)
112 return t, nil
113}
114
115func (t *Terminal) runReplies(ctx context.Context) {
116 defer close(t.done)
117 for {
118 select {
119 case <-ctx.Done():
120 _ = t.inW.CloseWithError(ctx.Err())
121 return
122 case response := <-t.replies:
123 if _, err := t.inW.Write(response); err != nil {
124 return
125 }
126 }
127 }
128}
129
130// EnableKittyKeyboard is a no-op: a virtual terminal has no outer keyboard.
131func (t *Terminal) EnableKittyKeyboard(int) error { return nil }
132
133func (t *Terminal) EnterRaw() (func() error, error) {
134 if _, err := t.Write(term.VisualEnterSequence()); err != nil {
135 return nil, err
136 }
137 if err := t.Flush(); err != nil {
138 return nil, err
139 }
140 var once sync.Once
141 var restoreErr error
142 return func() error {
143 once.Do(func() {
144 _, restoreErr = t.Write(term.VisualRestoreSequence())
145 if restoreErr == nil {
146 restoreErr = t.Flush()
147 }
148 t.Close()
149 })
150 return restoreErr
151 }, nil
152}
153
154func (t *Terminal) Geometry() (domain.Geometry, error) {
155 t.mu.Lock()
156 defer t.mu.Unlock()
157 if t.closed {
158 return domain.Geometry{}, io.ErrClosedPipe
159 }
160 return t.geometry, nil
161}
162
163func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
164 ch := make(chan domain.Geometry)
165 close(ch)
166 return ch
167}
168
169func (t *Terminal) In() io.Reader { return t.inR }
170func (t *Terminal) Out() io.Writer { return t }
171
172func (t *Terminal) Write(data []byte) (int, error) {
173 t.mu.Lock()
174 defer t.mu.Unlock()
175 if t.closed {
176 return 0, io.ErrClosedPipe
177 }
178 if t.screen != nil {
179 t.screen.Write(data)
180 }
181 t.dirty = true
182 if !t.available {
183 return len(data), errors.New("uiterm: terminal response queue overflow")
184 }
185 return len(data), nil
186}
187
188func (t *Terminal) Flush() error {
189 t.mu.Lock()
190 defer t.mu.Unlock()
191 if t.closed {
192 return io.ErrClosedPipe
193 }
194 if t.txDepth == 0 {
195 t.publishLocked()
196 }
197 return nil
198}
199
200func (t *Terminal) BeginOutput(context ports.UIContext) {
201 t.mu.Lock()
202 defer t.mu.Unlock()
203 if t.txDepth == 0 {
204 context = t.completeContextLocked(context)
205 t.context = context
206 t.txFailed = false
207 }
208 t.txDepth++
209}
210
211func (t *Terminal) EndOutput(success bool) {
212 t.mu.Lock()
213 defer t.mu.Unlock()
214 if t.txDepth == 0 {
215 return
216 }
217 if !success {
218 t.txFailed = true
219 }
220 t.txDepth--
221 if t.txDepth != 0 {
222 return
223 }
224 if t.txFailed {
225 t.available = false
226 t.signalChange()
227 return
228 }
229 t.publishLocked()
230}
231
232func (t *Terminal) PublishContext(context ports.UIContext) error {
233 t.mu.Lock()
234 defer t.mu.Unlock()
235 if t.closed || !t.available {
236 return ports.ErrUIUnavailable
237 }
238 t.context = t.completeContextLocked(context)
239 t.publishLocked()
240 return nil
241}
242
243// completeContextLocked applies the published-context shape rule for one
244// published context. Only an Attached presentation may carry a session
245// identity, a committed output boundary, or an actionable generation, and only
246// it inherits a missing handle or generation from the previous context. Picker
247// and Connecting are normalised to the handle and status they state, so an
248// unattached publication can never expose an old attachment's identity or a
249// generation no attachment committed.
250func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContext {
251 if context.Status != ports.UIStatusAttached {
252 return ports.UIContext{AttachmentHandle: context.AttachmentHandle, Status: context.Status}
253 }
254 if context.AttachmentHandle == "" {
255 context.AttachmentHandle = t.context.AttachmentHandle
256 }
257 if context.Generation == 0 {
258 context.Generation = t.context.Generation
259 }
260 return context
261}
262
263// capturableLocked applies the shared availability rules for a read of the
264// latest publication. The caller must hold t.mu.
265func (t *Terminal) capturableLocked() error {
266 if !t.available {
267 return ports.ErrUIUnavailable
268 }
269 if t.captureTooLarge {
270 return &ports.UIError{Code: ports.UIErrCaptureTooLarge}
271 }
272 if t.revision == 0 {
273 return ports.ErrUIUnavailable
274 }
275 return nil
276}
277
278func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
279 t.mu.Lock()
280 defer t.mu.Unlock()
281 if err := t.capturableLocked(); err != nil {
282 return ports.UISnapshot{}, err
283 }
284 if t.latest.Revision != t.revision {
285 t.latest = convertSnapshot(t.published, t.publishedGeom, t.publishedCtx, t.revision)
286 }
287 return cloneSnapshot(t.latest), nil
288}
289
290// Boundary reports the latest publication's revision and context without
291// converting its cells.
292func (t *Terminal) Boundary() (ports.UIActionResult, error) {
293 t.mu.Lock()
294 defer t.mu.Unlock()
295 if err := t.capturableLocked(); err != nil {
296 return ports.UIActionResult{}, err
297 }
298 return ports.UIActionResult{Revision: t.revision, Context: t.publishedCtx}, nil
299}
300
301func (t *Terminal) Changes() <-chan struct{} { return t.changes }
302
303func (t *Terminal) ObserveTerminalWrite(data []byte) {
304 _, _ = t.Write(data)
305}
306
307func (t *Terminal) ObserveTerminalFlush() {
308 _ = t.Flush()
309}
310
311func (t *Terminal) ObserveTerminalResize(geometry domain.Geometry) {
312 geometry = geometry.NormalizePixels()
313 t.mu.Lock()
314 defer t.mu.Unlock()
315 if t.closed {
316 return
317 }
318 if geometry.Cols <= 0 || geometry.Rows <= 0 {
319 t.available = false
320 t.signalChange()
321 return
322 }
323 if geometry.Cols > MaxColumns || geometry.Rows > MaxRows {
324 t.geometry = geometry
325 t.captureTooLarge = true
326 t.dirty = true
327 t.signalChange()
328 return
329 }
330 if t.screen == nil || t.captureTooLarge && t.mirror {
331 // A mirror cannot reconstruct the pixels that were emitted while its
332 // viewport was oversized. Keep capture_too_large rather than exposing
333 // a new blank or stale screen as current.
334 t.geometry = geometry
335 t.signalChange()
336 return
337 }
338 if t.captureTooLarge {
339 t.captureTooLarge = false
340 }
341 if t.geometry == geometry {
342 return
343 }
344 t.geometry = geometry
345 t.screen.Resize(geometry.Cols, geometry.Rows)
346 t.dirty = true
347 if t.txDepth == 0 {
348 t.publishLocked()
349 }
350}
351
352func (t *Terminal) InvalidateTerminalObservation() {
353 t.mu.Lock()
354 t.available = false
355 t.mu.Unlock()
356 t.signalChange()
357}
358
359func (t *Terminal) Close() {
360 t.mu.Lock()
361 if t.closed {
362 t.mu.Unlock()
363 return
364 }
365 t.closed = true
366 t.available = false
367 t.cancel()
368 _ = t.inR.Close()
369 t.mu.Unlock()
370 <-t.done
371 t.signalChange()
372}
373
374func (t *Terminal) publishLocked() {
375 if !t.available || t.captureTooLarge || t.screen == nil {
376 return
377 }
378 if t.dirty || t.revision == 0 {
379 t.published = t.screen.Snapshot()
380 }
381 t.publishedGeom = t.geometry
382 t.publishedCtx = t.context
383 t.revision++
384 t.dirty = false
385 t.signalChange()
386}
387
388func (t *Terminal) signalChange() {
389 select {
390 case t.changes <- struct{}{}:
391 default:
392 }
393}
394
395func convertSnapshot(screen vt.ScreenSnapshot, geometry domain.Geometry, context ports.UIContext, revision uint64) ports.UISnapshot {
396 cells := make([]ports.UICell, 0, screen.Columns()*screen.Rows())
397 for row := 0; row < screen.Rows(); row++ {
398 for column := 0; column < screen.Columns(); column++ {
399 cell := screen.Cell(column, row)
400 text := ""
401 width := 0
402 if !cell.Continuation {
403 text = string(cell.Rune)
404 width = core.RuneWidth(cell.Rune)
405 }
406 cells = append(cells, ports.UICell{Text: text, Width: width, Continuation: cell.Continuation, Style: convertStyle(cell.Style)})
407 }
408 }
409 cursor := screen.Cursor()
410 return ports.UISnapshot{
411 Revision: revision, Context: context,
412 Columns: geometry.Cols, Rows: geometry.Rows,
413 Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet},
414 Cells: cells, AutoWrap: screen.AutoWrapMode(), ApplicationCursor: screen.ApplicationCursorMode(),
415 }
416}
417
418func convertStyle(style core.Style) ports.UIStyle {
419 return ports.UIStyle{
420 Foreground: convertColor(style.Foreground, style.ForegroundRGB, style.HasForegroundRGB),
421 Background: convertColor(style.Background, style.BackgroundRGB, style.HasBackgroundRGB),
422 Bold: style.Bold, Dim: style.Attrs&core.AttrDim != 0, Italic: style.Italic,
423 Underline: style.Attrs&core.AttrUnderline != 0, UnderlineStyle: uint8(style.UnderlineStyle),
424 Blink: style.Attrs&core.AttrBlink != 0, Inverse: style.Inverse,
425 Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
426 }
427}
428
429func convertColor(index int, rgb core.RGB, hasRGB bool) ports.UIColor {
430 if hasRGB {
431 return ports.UIColor{Kind: ports.UIColorRGB, R: rgb.R, G: rgb.G, B: rgb.B}
432 }
433 if index >= 0 && index <= 255 {
434 return ports.UIColor{Kind: ports.UIColorIndexed, Index: uint8(index)}
435 }
436 return ports.UIColor{Kind: ports.UIColorDefault}
437}
438
439func cloneSnapshot(snapshot ports.UISnapshot) ports.UISnapshot {
440 snapshot.Cells = append([]ports.UICell(nil), snapshot.Cells...)
441 return snapshot
442}