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