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	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}

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