v0.7.0 4c16dd8

terminal.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close