detached 786a71f

feat(term): client terminal adapter — raw mode, alt-screen, SIGWINCH coalescing, batched writer

Implements ports.Terminal for the client-side controlling terminal:
idempotent EnterRaw/restore (x/term MakeRaw/Restore, alt-screen
1049h/l, cursor show/hide), Size via x/term.GetSize, ResizeEvents with
a lazily-started SIGWINCH watcher that drain-and-coalesces bursts to
the latest size, and a bufio-backed 64KiB batched writer for Out/Flush.

Raw-mode syscalls are skipped (and recorded) when the backing fd isn't
a tty, so EnterRaw stays testable over pipes; one integration test
exercises the real-tty paths against a self-contained /dev/ptmx PTY
pair. Adds golang.org/x/term and golang.org/x/sys (test-only) deps.
commit
786a71f2f545252012c8c4f1ee1ea3418e921a22
author
brice · · 2026-07-02 11:07 UTC
committer
brice · · 2026-07-02 11:16 UTC
parent
6b2d35b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +893 -0
M go.mod +1 -0
@@ -5,6 +5,7 @@ go 1.26
55 require (
66 github.com/stretchr/testify v1.11.1
77 golang.org/x/sys v0.46.0
8+ golang.org/x/term v0.44.0
89 )
910
1011 require (
@@ -5,6 +5,7 @@ go 1.26
5 require (5 require (
6 github.com/stretchr/testify v1.11.16 github.com/stretchr/testify v1.11.1
7 golang.org/x/sys v0.46.07 golang.org/x/sys v0.46.0
8+ golang.org/x/term v0.44.0
8 )9 )
9 10
10 require (11 require (
M go.sum +2 -0 generated file: collapsed
A internal/adapters/term/batchwriter.go +51 -0
@@ -0,0 +1,51 @@
1+package term
2+
3+import (
4+ "bufio"
5+ "io"
6+ "sync"
7+)
8+
9+// batchWriter is a thread-safe buffered writer. Writes accumulate in an
10+// internal buffer until Flush is called; a single write larger than the
11+// buffer's capacity passes straight through to the underlying sink
12+// (bufio.Writer's standard behavior).
13+type batchWriter struct {
14+ mu sync.Mutex
15+ bw *bufio.Writer
16+}
17+
18+// newBatchWriter wraps out with a buffer of the given size.
19+func newBatchWriter(out io.Writer, size int) *batchWriter {
20+ return &batchWriter{bw: bufio.NewWriterSize(out, size)}
21+}
22+
23+// Write buffers p, flushing to the underlying sink only as bufio.Writer
24+// requires (e.g. when p would overflow the buffer).
25+func (w *batchWriter) Write(p []byte) (int, error) {
26+ w.mu.Lock()
27+ defer w.mu.Unlock()
28+ return w.bw.Write(p)
29+}
30+
31+// WriteString is the string analogue of Write.
32+func (w *batchWriter) WriteString(s string) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+ return w.bw.WriteString(s)
36+}
37+
38+// Flush writes any buffered data to the underlying sink.
39+func (w *batchWriter) Flush() error {
40+ w.mu.Lock()
41+ defer w.mu.Unlock()
42+ return w.bw.Flush()
43+}
44+
45+// Buffered reports the number of bytes currently buffered and not yet
46+// flushed to the underlying sink.
47+func (w *batchWriter) Buffered() int {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+ return w.bw.Buffered()
51+}
@@ -0,0 +1,51 @@
1+package term
2+
3+import (
4+ "bufio"
5+ "io"
6+ "sync"
7+)
8+
9+// batchWriter is a thread-safe buffered writer. Writes accumulate in an
10+// internal buffer until Flush is called; a single write larger than the
11+// buffer's capacity passes straight through to the underlying sink
12+// (bufio.Writer's standard behavior).
13+type batchWriter struct {
14+ mu sync.Mutex
15+ bw *bufio.Writer
16+}
17+
18+// newBatchWriter wraps out with a buffer of the given size.
19+func newBatchWriter(out io.Writer, size int) *batchWriter {
20+ return &batchWriter{bw: bufio.NewWriterSize(out, size)}
21+}
22+
23+// Write buffers p, flushing to the underlying sink only as bufio.Writer
24+// requires (e.g. when p would overflow the buffer).
25+func (w *batchWriter) Write(p []byte) (int, error) {
26+ w.mu.Lock()
27+ defer w.mu.Unlock()
28+ return w.bw.Write(p)
29+}
30+
31+// WriteString is the string analogue of Write.
32+func (w *batchWriter) WriteString(s string) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+ return w.bw.WriteString(s)
36+}
37+
38+// Flush writes any buffered data to the underlying sink.
39+func (w *batchWriter) Flush() error {
40+ w.mu.Lock()
41+ defer w.mu.Unlock()
42+ return w.bw.Flush()
43+}
44+
45+// Buffered reports the number of bytes currently buffered and not yet
46+// flushed to the underlying sink.
47+func (w *batchWriter) Buffered() int {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+ return w.bw.Buffered()
51+}
A internal/adapters/term/batchwriter_test.go +130 -0
@@ -0,0 +1,130 @@
1+package term
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+// recordingWriter records each Write call it receives, copying the bytes
9+// so later mutations by the caller (e.g. buffer reuse) don't corrupt the
10+// recorded history.
11+type recordingWriter struct {
12+ writes [][]byte
13+ failNext bool
14+}
15+
16+func (r *recordingWriter) Write(p []byte) (int, error) {
17+ if r.failNext {
18+ r.failNext = false
19+ return 0, errors.New("boom")
20+ }
21+ cp := make([]byte, len(p))
22+ copy(cp, p)
23+ r.writes = append(r.writes, cp)
24+ return len(p), nil
25+}
26+
27+func (r *recordingWriter) all() []byte {
28+ var out []byte
29+ for _, w := range r.writes {
30+ out = append(out, w...)
31+ }
32+ return out
33+}
34+
35+func TestBatchWriter_BufferedUntilFlush(t *testing.T) {
36+ rw := &recordingWriter{}
37+ bw := newBatchWriter(rw, 64)
38+
39+ n, err := bw.Write([]byte("hello"))
40+ if err != nil || n != 5 {
41+ t.Fatalf("Write() = %d, %v; want 5, nil", n, err)
42+ }
43+ if len(rw.writes) != 0 {
44+ t.Fatalf("expected no writes reaching sink before Flush, got %d", len(rw.writes))
45+ }
46+ if got := bw.Buffered(); got != 5 {
47+ t.Fatalf("Buffered() = %d, want 5", got)
48+ }
49+
50+ if err := bw.Flush(); err != nil {
51+ t.Fatalf("Flush() = %v, want nil", err)
52+ }
53+ if got := string(rw.all()); got != "hello" {
54+ t.Fatalf("sink got %q, want %q", got, "hello")
55+ }
56+ if got := bw.Buffered(); got != 0 {
57+ t.Fatalf("Buffered() after Flush = %d, want 0", got)
58+ }
59+}
60+
61+func TestBatchWriter_MultipleWritesCoalesceUntilFlush(t *testing.T) {
62+ rw := &recordingWriter{}
63+ bw := newBatchWriter(rw, 64)
64+
65+ for _, s := range []string{"a", "b", "c"} {
66+ if _, err := bw.WriteString(s); err != nil {
67+ t.Fatalf("WriteString(%q): %v", s, err)
68+ }
69+ }
70+ if len(rw.writes) != 0 {
71+ t.Fatalf("expected 0 writes to sink before Flush, got %d", len(rw.writes))
72+ }
73+
74+ if err := bw.Flush(); err != nil {
75+ t.Fatalf("Flush(): %v", err)
76+ }
77+ if got := string(rw.all()); got != "abc" {
78+ t.Fatalf("sink got %q, want %q", got, "abc")
79+ }
80+ // bufio.Writer.Flush issues a single underlying Write.
81+ if len(rw.writes) != 1 {
82+ t.Fatalf("expected exactly 1 write to sink on Flush, got %d", len(rw.writes))
83+ }
84+}
85+
86+func TestBatchWriter_LargeWritePassesThrough(t *testing.T) {
87+ rw := &recordingWriter{}
88+ bw := newBatchWriter(rw, 8) // small buffer
89+
90+ big := make([]byte, 64)
91+ for i := range big {
92+ big[i] = 'x'
93+ }
94+
95+ n, err := bw.Write(big)
96+ if err != nil || n != len(big) {
97+ t.Fatalf("Write(big) = %d, %v; want %d, nil", n, err, len(big))
98+ }
99+ // A write larger than the buffer bypasses buffering entirely.
100+ if len(rw.writes) == 0 {
101+ t.Fatalf("expected large write to pass through immediately, got 0 sink writes")
102+ }
103+ if got := string(rw.all()); got != string(big) {
104+ t.Fatalf("sink content mismatch: got %d bytes, want %d bytes", len(got), len(big))
105+ }
106+}
107+
108+func TestBatchWriter_FlushPropagatesError(t *testing.T) {
109+ rw := &recordingWriter{failNext: true}
110+ bw := newBatchWriter(rw, 64)
111+
112+ if _, err := bw.WriteString("x"); err != nil {
113+ t.Fatalf("WriteString: %v", err)
114+ }
115+ if err := bw.Flush(); err == nil {
116+ t.Fatalf("expected Flush error, got nil")
117+ }
118+}
119+
120+func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) {
121+ rw := &recordingWriter{}
122+ bw := newBatchWriter(rw, 64)
123+
124+ if err := bw.Flush(); err != nil {
125+ t.Fatalf("Flush() on empty buffer: %v", err)
126+ }
127+ if len(rw.writes) != 0 {
128+ t.Fatalf("expected no sink writes, got %d", len(rw.writes))
129+ }
130+}
@@ -0,0 +1,130 @@
1+package term
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+// recordingWriter records each Write call it receives, copying the bytes
9+// so later mutations by the caller (e.g. buffer reuse) don't corrupt the
10+// recorded history.
11+type recordingWriter struct {
12+ writes [][]byte
13+ failNext bool
14+}
15+
16+func (r *recordingWriter) Write(p []byte) (int, error) {
17+ if r.failNext {
18+ r.failNext = false
19+ return 0, errors.New("boom")
20+ }
21+ cp := make([]byte, len(p))
22+ copy(cp, p)
23+ r.writes = append(r.writes, cp)
24+ return len(p), nil
25+}
26+
27+func (r *recordingWriter) all() []byte {
28+ var out []byte
29+ for _, w := range r.writes {
30+ out = append(out, w...)
31+ }
32+ return out
33+}
34+
35+func TestBatchWriter_BufferedUntilFlush(t *testing.T) {
36+ rw := &recordingWriter{}
37+ bw := newBatchWriter(rw, 64)
38+
39+ n, err := bw.Write([]byte("hello"))
40+ if err != nil || n != 5 {
41+ t.Fatalf("Write() = %d, %v; want 5, nil", n, err)
42+ }
43+ if len(rw.writes) != 0 {
44+ t.Fatalf("expected no writes reaching sink before Flush, got %d", len(rw.writes))
45+ }
46+ if got := bw.Buffered(); got != 5 {
47+ t.Fatalf("Buffered() = %d, want 5", got)
48+ }
49+
50+ if err := bw.Flush(); err != nil {
51+ t.Fatalf("Flush() = %v, want nil", err)
52+ }
53+ if got := string(rw.all()); got != "hello" {
54+ t.Fatalf("sink got %q, want %q", got, "hello")
55+ }
56+ if got := bw.Buffered(); got != 0 {
57+ t.Fatalf("Buffered() after Flush = %d, want 0", got)
58+ }
59+}
60+
61+func TestBatchWriter_MultipleWritesCoalesceUntilFlush(t *testing.T) {
62+ rw := &recordingWriter{}
63+ bw := newBatchWriter(rw, 64)
64+
65+ for _, s := range []string{"a", "b", "c"} {
66+ if _, err := bw.WriteString(s); err != nil {
67+ t.Fatalf("WriteString(%q): %v", s, err)
68+ }
69+ }
70+ if len(rw.writes) != 0 {
71+ t.Fatalf("expected 0 writes to sink before Flush, got %d", len(rw.writes))
72+ }
73+
74+ if err := bw.Flush(); err != nil {
75+ t.Fatalf("Flush(): %v", err)
76+ }
77+ if got := string(rw.all()); got != "abc" {
78+ t.Fatalf("sink got %q, want %q", got, "abc")
79+ }
80+ // bufio.Writer.Flush issues a single underlying Write.
81+ if len(rw.writes) != 1 {
82+ t.Fatalf("expected exactly 1 write to sink on Flush, got %d", len(rw.writes))
83+ }
84+}
85+
86+func TestBatchWriter_LargeWritePassesThrough(t *testing.T) {
87+ rw := &recordingWriter{}
88+ bw := newBatchWriter(rw, 8) // small buffer
89+
90+ big := make([]byte, 64)
91+ for i := range big {
92+ big[i] = 'x'
93+ }
94+
95+ n, err := bw.Write(big)
96+ if err != nil || n != len(big) {
97+ t.Fatalf("Write(big) = %d, %v; want %d, nil", n, err, len(big))
98+ }
99+ // A write larger than the buffer bypasses buffering entirely.
100+ if len(rw.writes) == 0 {
101+ t.Fatalf("expected large write to pass through immediately, got 0 sink writes")
102+ }
103+ if got := string(rw.all()); got != string(big) {
104+ t.Fatalf("sink content mismatch: got %d bytes, want %d bytes", len(got), len(big))
105+ }
106+}
107+
108+func TestBatchWriter_FlushPropagatesError(t *testing.T) {
109+ rw := &recordingWriter{failNext: true}
110+ bw := newBatchWriter(rw, 64)
111+
112+ if _, err := bw.WriteString("x"); err != nil {
113+ t.Fatalf("WriteString: %v", err)
114+ }
115+ if err := bw.Flush(); err == nil {
116+ t.Fatalf("expected Flush error, got nil")
117+ }
118+}
119+
120+func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) {
121+ rw := &recordingWriter{}
122+ bw := newBatchWriter(rw, 64)
123+
124+ if err := bw.Flush(); err != nil {
125+ t.Fatalf("Flush() on empty buffer: %v", err)
126+ }
127+ if len(rw.writes) != 0 {
128+ t.Fatalf("expected no sink writes, got %d", len(rw.writes))
129+ }
130+}
A internal/adapters/term/pty_integration_test.go +112 -0
@@ -0,0 +1,112 @@
1+//go:build linux
2+
3+package term
4+
5+import (
6+ "fmt"
7+ "io"
8+ "os"
9+ "testing"
10+ "time"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// openPTYPair opens a real Linux PTY master/slave pair directly via
16+// /dev/ptmx, self-contained (no import of the vev pty adapter package,
17+// which is being built in parallel).
18+func openPTYPair(t *testing.T) (master, slave *os.File) {
19+ t.Helper()
20+
21+ m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0)
22+ if err != nil {
23+ t.Fatalf("open /dev/ptmx: %v", err)
24+ }
25+
26+ if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil {
27+ m.Close()
28+ t.Fatalf("unlock pty (TIOCSPTLCK): %v", err)
29+ }
30+
31+ n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN)
32+ if err != nil {
33+ m.Close()
34+ t.Fatalf("get pty number (TIOCGPTN): %v", err)
35+ }
36+
37+ slavePath := fmt.Sprintf("/dev/pts/%d", n)
38+ s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0)
39+ if err != nil {
40+ m.Close()
41+ t.Fatalf("open slave %s: %v", slavePath, err)
42+ }
43+
44+ return m, s
45+}
46+
47+// TestTerminal_RealPTY_RawModeSizeAndEscapes is the ONE integration test
48+// exercising the real-tty paths (MakeRaw/Restore, GetSize) against an
49+// actual PTY, since those are pure-syscall lines that unit tests with
50+// pipes can't cover.
51+func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
52+ master, slave := openPTYPair(t)
53+ defer master.Close()
54+ defer slave.Close()
55+
56+ ws := &unix.Winsize{Row: 24, Col: 80}
57+ if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil {
58+ t.Fatalf("set winsize: %v", err)
59+ }
60+
61+ // Drain everything written to the slave (escape sequences) so the
62+ // batched writer's Flush never blocks on the PTY's internal buffer.
63+ captured := make(chan []byte, 1)
64+ go func() {
65+ buf, _ := io.ReadAll(master)
66+ captured <- buf
67+ }()
68+
69+ tm := NewWithFiles(slave, slave)
70+
71+ sz, err := tm.Size()
72+ if err != nil {
73+ t.Fatalf("Size: %v", err)
74+ }
75+ if sz.Cols != 80 || sz.Rows != 24 {
76+ t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz)
77+ }
78+
79+ restore, err := tm.EnterRaw()
80+ if err != nil {
81+ t.Fatalf("EnterRaw: %v", err)
82+ }
83+ if tm.rawSkipped {
84+ t.Fatalf("expected rawSkipped=false for a real PTY")
85+ }
86+
87+ if err := restore(); err != nil {
88+ t.Fatalf("restore: %v", err)
89+ }
90+ // Idempotent.
91+ if err := restore(); err != nil {
92+ t.Fatalf("second restore: %v", err)
93+ }
94+
95+ // Closing the slave lets the reader goroutine observe EOF on the
96+ // master naturally. Don't also close master here: that would race
97+ // the reader's in-flight Read against this goroutine's Close on the
98+ // same fd and could discard already-buffered-but-unread data.
99+ if err := slave.Close(); err != nil {
100+ t.Fatalf("close slave: %v", err)
101+ }
102+
103+ select {
104+ case got := <-captured:
105+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
106+ if string(got) != want {
107+ t.Fatalf("captured escapes = %q, want %q", got, want)
108+ }
109+ case <-time.After(5 * time.Second):
110+ t.Fatalf("timed out waiting for captured PTY output")
111+ }
112+}
@@ -0,0 +1,112 @@
1+//go:build linux
2+
3+package term
4+
5+import (
6+ "fmt"
7+ "io"
8+ "os"
9+ "testing"
10+ "time"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// openPTYPair opens a real Linux PTY master/slave pair directly via
16+// /dev/ptmx, self-contained (no import of the vev pty adapter package,
17+// which is being built in parallel).
18+func openPTYPair(t *testing.T) (master, slave *os.File) {
19+ t.Helper()
20+
21+ m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0)
22+ if err != nil {
23+ t.Fatalf("open /dev/ptmx: %v", err)
24+ }
25+
26+ if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil {
27+ m.Close()
28+ t.Fatalf("unlock pty (TIOCSPTLCK): %v", err)
29+ }
30+
31+ n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN)
32+ if err != nil {
33+ m.Close()
34+ t.Fatalf("get pty number (TIOCGPTN): %v", err)
35+ }
36+
37+ slavePath := fmt.Sprintf("/dev/pts/%d", n)
38+ s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0)
39+ if err != nil {
40+ m.Close()
41+ t.Fatalf("open slave %s: %v", slavePath, err)
42+ }
43+
44+ return m, s
45+}
46+
47+// TestTerminal_RealPTY_RawModeSizeAndEscapes is the ONE integration test
48+// exercising the real-tty paths (MakeRaw/Restore, GetSize) against an
49+// actual PTY, since those are pure-syscall lines that unit tests with
50+// pipes can't cover.
51+func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
52+ master, slave := openPTYPair(t)
53+ defer master.Close()
54+ defer slave.Close()
55+
56+ ws := &unix.Winsize{Row: 24, Col: 80}
57+ if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil {
58+ t.Fatalf("set winsize: %v", err)
59+ }
60+
61+ // Drain everything written to the slave (escape sequences) so the
62+ // batched writer's Flush never blocks on the PTY's internal buffer.
63+ captured := make(chan []byte, 1)
64+ go func() {
65+ buf, _ := io.ReadAll(master)
66+ captured <- buf
67+ }()
68+
69+ tm := NewWithFiles(slave, slave)
70+
71+ sz, err := tm.Size()
72+ if err != nil {
73+ t.Fatalf("Size: %v", err)
74+ }
75+ if sz.Cols != 80 || sz.Rows != 24 {
76+ t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz)
77+ }
78+
79+ restore, err := tm.EnterRaw()
80+ if err != nil {
81+ t.Fatalf("EnterRaw: %v", err)
82+ }
83+ if tm.rawSkipped {
84+ t.Fatalf("expected rawSkipped=false for a real PTY")
85+ }
86+
87+ if err := restore(); err != nil {
88+ t.Fatalf("restore: %v", err)
89+ }
90+ // Idempotent.
91+ if err := restore(); err != nil {
92+ t.Fatalf("second restore: %v", err)
93+ }
94+
95+ // Closing the slave lets the reader goroutine observe EOF on the
96+ // master naturally. Don't also close master here: that would race
97+ // the reader's in-flight Read against this goroutine's Close on the
98+ // same fd and could discard already-buffered-but-unread data.
99+ if err := slave.Close(); err != nil {
100+ t.Fatalf("close slave: %v", err)
101+ }
102+
103+ select {
104+ case got := <-captured:
105+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
106+ if string(got) != want {
107+ t.Fatalf("captured escapes = %q, want %q", got, want)
108+ }
109+ case <-time.After(5 * time.Second):
110+ t.Fatalf("timed out waiting for captured PTY output")
111+ }
112+}
A internal/adapters/term/resize.go +50 -0
@@ -0,0 +1,50 @@
1+package term
2+
3+import (
4+ "os"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// resizeLoop watches sig for signal notifications (SIGWINCH in
10+// production) and emits the coalesced terminal size on out: a burst of
11+// signals collapses to a single emitted size, since any signals still
12+// queued once the first is observed are drained before querying the
13+// size. getSize is called to resolve the size to emit; a getSize error
14+// is treated as "no event this round" and the loop continues waiting.
15+//
16+// The loop exits and closes out when quit is closed.
17+func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) {
18+ defer close(out)
19+ for {
20+ select {
21+ case <-quit:
22+ return
23+ case <-sig:
24+ drainSignals(sig)
25+ sz, err := getSize()
26+ if err != nil {
27+ continue
28+ }
29+ select {
30+ case out <- sz:
31+ case <-quit:
32+ return
33+ }
34+ }
35+ }
36+}
37+
38+// drainSignals removes any currently queued signals from sig without
39+// blocking. Called right after receiving one signal, it collapses a
40+// burst that arrived (or queued up) concurrently into that single
41+// wakeup.
42+func drainSignals(sig <-chan os.Signal) {
43+ for {
44+ select {
45+ case <-sig:
46+ default:
47+ return
48+ }
49+ }
50+}
@@ -0,0 +1,50 @@
1+package term
2+
3+import (
4+ "os"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// resizeLoop watches sig for signal notifications (SIGWINCH in
10+// production) and emits the coalesced terminal size on out: a burst of
11+// signals collapses to a single emitted size, since any signals still
12+// queued once the first is observed are drained before querying the
13+// size. getSize is called to resolve the size to emit; a getSize error
14+// is treated as "no event this round" and the loop continues waiting.
15+//
16+// The loop exits and closes out when quit is closed.
17+func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) {
18+ defer close(out)
19+ for {
20+ select {
21+ case <-quit:
22+ return
23+ case <-sig:
24+ drainSignals(sig)
25+ sz, err := getSize()
26+ if err != nil {
27+ continue
28+ }
29+ select {
30+ case out <- sz:
31+ case <-quit:
32+ return
33+ }
34+ }
35+ }
36+}
37+
38+// drainSignals removes any currently queued signals from sig without
39+// blocking. Called right after receiving one signal, it collapses a
40+// burst that arrived (or queued up) concurrently into that single
41+// wakeup.
42+func drainSignals(sig <-chan os.Signal) {
43+ for {
44+ select {
45+ case <-sig:
46+ default:
47+ return
48+ }
49+ }
50+}
A internal/adapters/term/resize_test.go +125 -0
@@ -0,0 +1,125 @@
1+package term
2+
3+import (
4+ "errors"
5+ "os"
6+ "syscall"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
14+ // Fill the signal channel with a burst before the loop starts
15+ // consuming: draining is synchronous and deterministic here since
16+ // nothing else sends concurrently.
17+ sig := make(chan os.Signal, 8)
18+ for i := 0; i < 5; i++ {
19+ sig <- syscall.SIGWINCH
20+ }
21+
22+ out := make(chan domain.Size)
23+ quit := make(chan struct{})
24+
25+ var calls int
26+ getSize := func() (domain.Size, error) {
27+ calls++
28+ return domain.Size{Cols: 80, Rows: 24}, nil
29+ }
30+
31+ go resizeLoop(sig, out, quit, getSize)
32+
33+ got := <-out
34+ want := domain.Size{Cols: 80, Rows: 24}
35+ if got != want {
36+ t.Fatalf("emitted size = %+v, want %+v", got, want)
37+ }
38+
39+ select {
40+ case sz2, ok := <-out:
41+ t.Fatalf("unexpected second emission (ok=%v): %+v", ok, sz2)
42+ case <-time.After(50 * time.Millisecond):
43+ }
44+
45+ if calls != 1 {
46+ t.Fatalf("getSize called %d times, want 1 (burst should coalesce)", calls)
47+ }
48+
49+ close(quit)
50+ if _, ok := <-out; ok {
51+ t.Fatalf("expected out to be closed after quit")
52+ }
53+}
54+
55+func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
56+ sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive
57+ out := make(chan domain.Size)
58+ quit := make(chan struct{})
59+
60+ var calls int
61+ getSize := func() (domain.Size, error) {
62+ calls++
63+ if calls == 1 {
64+ return domain.Size{}, errors.New("boom")
65+ }
66+ return domain.Size{Cols: 10, Rows: 5}, nil
67+ }
68+
69+ go resizeLoop(sig, out, quit, getSize)
70+
71+ sig <- syscall.SIGWINCH // errors; no emission
72+ sig <- syscall.SIGWINCH // succeeds
73+
74+ got := <-out
75+ want := domain.Size{Cols: 10, Rows: 5}
76+ if got != want {
77+ t.Fatalf("emitted size = %+v, want %+v", got, want)
78+ }
79+ if calls != 2 {
80+ t.Fatalf("getSize called %d times, want 2", calls)
81+ }
82+
83+ close(quit)
84+ if _, ok := <-out; ok {
85+ t.Fatalf("expected out to be closed after quit")
86+ }
87+}
88+
89+func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) {
90+ sig := make(chan os.Signal)
91+ out := make(chan domain.Size)
92+ quit := make(chan struct{})
93+
94+ getSize := func() (domain.Size, error) {
95+ t.Fatalf("getSize should not be called")
96+ return domain.Size{}, nil
97+ }
98+
99+ go resizeLoop(sig, out, quit, getSize)
100+ close(quit)
101+
102+ select {
103+ case _, ok := <-out:
104+ if ok {
105+ t.Fatalf("expected out to be closed, got a value")
106+ }
107+ case <-time.After(time.Second):
108+ t.Fatalf("out was not closed after quit")
109+ }
110+}
111+
112+func TestDrainSignals_RemovesQueuedSignalsOnly(t *testing.T) {
113+ sig := make(chan os.Signal, 4)
114+ sig <- syscall.SIGWINCH
115+ sig <- syscall.SIGWINCH
116+ sig <- syscall.SIGWINCH
117+
118+ drainSignals(sig)
119+
120+ select {
121+ case s := <-sig:
122+ t.Fatalf("expected sig to be empty after drain, got %v", s)
123+ default:
124+ }
125+}
@@ -0,0 +1,125 @@
1+package term
2+
3+import (
4+ "errors"
5+ "os"
6+ "syscall"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
14+ // Fill the signal channel with a burst before the loop starts
15+ // consuming: draining is synchronous and deterministic here since
16+ // nothing else sends concurrently.
17+ sig := make(chan os.Signal, 8)
18+ for i := 0; i < 5; i++ {
19+ sig <- syscall.SIGWINCH
20+ }
21+
22+ out := make(chan domain.Size)
23+ quit := make(chan struct{})
24+
25+ var calls int
26+ getSize := func() (domain.Size, error) {
27+ calls++
28+ return domain.Size{Cols: 80, Rows: 24}, nil
29+ }
30+
31+ go resizeLoop(sig, out, quit, getSize)
32+
33+ got := <-out
34+ want := domain.Size{Cols: 80, Rows: 24}
35+ if got != want {
36+ t.Fatalf("emitted size = %+v, want %+v", got, want)
37+ }
38+
39+ select {
40+ case sz2, ok := <-out:
41+ t.Fatalf("unexpected second emission (ok=%v): %+v", ok, sz2)
42+ case <-time.After(50 * time.Millisecond):
43+ }
44+
45+ if calls != 1 {
46+ t.Fatalf("getSize called %d times, want 1 (burst should coalesce)", calls)
47+ }
48+
49+ close(quit)
50+ if _, ok := <-out; ok {
51+ t.Fatalf("expected out to be closed after quit")
52+ }
53+}
54+
55+func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
56+ sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive
57+ out := make(chan domain.Size)
58+ quit := make(chan struct{})
59+
60+ var calls int
61+ getSize := func() (domain.Size, error) {
62+ calls++
63+ if calls == 1 {
64+ return domain.Size{}, errors.New("boom")
65+ }
66+ return domain.Size{Cols: 10, Rows: 5}, nil
67+ }
68+
69+ go resizeLoop(sig, out, quit, getSize)
70+
71+ sig <- syscall.SIGWINCH // errors; no emission
72+ sig <- syscall.SIGWINCH // succeeds
73+
74+ got := <-out
75+ want := domain.Size{Cols: 10, Rows: 5}
76+ if got != want {
77+ t.Fatalf("emitted size = %+v, want %+v", got, want)
78+ }
79+ if calls != 2 {
80+ t.Fatalf("getSize called %d times, want 2", calls)
81+ }
82+
83+ close(quit)
84+ if _, ok := <-out; ok {
85+ t.Fatalf("expected out to be closed after quit")
86+ }
87+}
88+
89+func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) {
90+ sig := make(chan os.Signal)
91+ out := make(chan domain.Size)
92+ quit := make(chan struct{})
93+
94+ getSize := func() (domain.Size, error) {
95+ t.Fatalf("getSize should not be called")
96+ return domain.Size{}, nil
97+ }
98+
99+ go resizeLoop(sig, out, quit, getSize)
100+ close(quit)
101+
102+ select {
103+ case _, ok := <-out:
104+ if ok {
105+ t.Fatalf("expected out to be closed, got a value")
106+ }
107+ case <-time.After(time.Second):
108+ t.Fatalf("out was not closed after quit")
109+ }
110+}
111+
112+func TestDrainSignals_RemovesQueuedSignalsOnly(t *testing.T) {
113+ sig := make(chan os.Signal, 4)
114+ sig <- syscall.SIGWINCH
115+ sig <- syscall.SIGWINCH
116+ sig <- syscall.SIGWINCH
117+
118+ drainSignals(sig)
119+
120+ select {
121+ case s := <-sig:
122+ t.Fatalf("expected sig to be empty after drain, got %v", s)
123+ default:
124+ }
125+}
A internal/adapters/term/terminal.go +224 -0
@@ -0,0 +1,224 @@
1+// Package term adapts vev's ports to terminal I/O implementations.
2+package term
3+
4+import (
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/signal"
9+ "sync"
10+ "syscall"
11+
12+ "golang.org/x/term"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+// Compile-time check that Terminal satisfies ports.Terminal.
19+var _ ports.Terminal = (*Terminal)(nil)
20+
21+// Escape sequences for alt-screen and cursor visibility control. All
22+// emissions go through the batched writer and are explicitly flushed.
23+const (
24+ altScreenEnter = "\x1b[?1049h"
25+ altScreenExit = "\x1b[?1049l"
26+ cursorHide = "\x1b[?25l"
27+ cursorShow = "\x1b[?25h"
28+)
29+
30+// bufSize is the batched writer's buffer capacity.
31+const bufSize = 64 * 1024
32+
33+// Terminal implements ports.Terminal for the client-side controlling
34+// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
35+// batched stdout writer.
36+//
37+// Zero value is not usable; use New or NewWithFiles.
38+type Terminal struct {
39+ in *os.File
40+ out *os.File
41+ fd int
42+ bw *batchWriter
43+
44+ mu sync.Mutex
45+ orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
46+ entered bool // true between a successful EnterRaw and its restore
47+ rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
48+ restoreFn func() error // the single idempotent restore closure for the current session
49+
50+ resizeStart sync.Once
51+ resizeStop sync.Once
52+ resizeCh chan domain.Size
53+ resizeQuit chan struct{}
54+ sigCh chan os.Signal
55+ resizeWG sync.WaitGroup
56+}
57+
58+// New creates a Terminal backed by os.Stdin and os.Stdout.
59+func New() *Terminal {
60+ return NewWithFiles(os.Stdin, os.Stdout)
61+}
62+
63+// NewWithFiles creates a Terminal backed by the given tty files. It
64+// exists so tests can inject a PTY pair (or a plain pipe, for the
65+// non-tty paths) in place of the process's real stdin/stdout.
66+func NewWithFiles(in, out *os.File) *Terminal {
67+ return &Terminal{
68+ in: in,
69+ out: out,
70+ fd: int(in.Fd()),
71+ bw: newBatchWriter(out, bufSize),
72+ }
73+}
74+
75+// EnterRaw puts the terminal into raw mode (when the input fd is
76+// actually a tty; otherwise the raw-mode syscalls are skipped and
77+// recorded, not treated as failure), enters the alt screen, and hides
78+// the cursor. The returned restore func idempotently reverses all of
79+// this; it is safe to call multiple times.
80+func (t *Terminal) EnterRaw() (func() error, error) {
81+ t.mu.Lock()
82+ defer t.mu.Unlock()
83+
84+ if t.entered {
85+ return t.restoreFn, nil
86+ }
87+
88+ var old *term.State
89+ if term.IsTerminal(t.fd) {
90+ s, err := term.MakeRaw(t.fd)
91+ if err != nil {
92+ return nil, fmt.Errorf("term: make raw: %w", err)
93+ }
94+ old = s
95+ t.rawSkipped = false
96+ } else {
97+ t.rawSkipped = true
98+ }
99+ t.orig = old
100+
101+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil {
102+ t.restoreRawLocked()
103+ return nil, fmt.Errorf("term: enter alt screen: %w", err)
104+ }
105+ if err := t.bw.Flush(); err != nil {
106+ t.restoreRawLocked()
107+ return nil, fmt.Errorf("term: enter alt screen: %w", err)
108+ }
109+
110+ t.entered = true
111+ t.restoreFn = t.makeRestoreLocked()
112+ return t.restoreFn, nil
113+}
114+
115+// makeRestoreLocked returns an idempotent restore closure for the
116+// current EnterRaw "session". Must be called with t.mu held.
117+func (t *Terminal) makeRestoreLocked() func() error {
118+ var once sync.Once
119+ var err error
120+ return func() error {
121+ once.Do(func() {
122+ err = t.restore()
123+ })
124+ return err
125+ }
126+}
127+
128+// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
129+// restores the original terminal mode (if raw mode was actually
130+// entered), and stops the resize watcher, closing its channel.
131+func (t *Terminal) restore() error {
132+ t.mu.Lock()
133+ defer t.mu.Unlock()
134+
135+ _, werr := t.bw.WriteString(cursorShow + altScreenExit)
136+ ferr := t.bw.Flush()
137+
138+ rerr := t.restoreRawLocked()
139+
140+ t.entered = false
141+ t.restoreFn = nil
142+
143+ t.stopResize()
144+
145+ switch {
146+ case werr != nil:
147+ return fmt.Errorf("term: restore: write: %w", werr)
148+ case ferr != nil:
149+ return fmt.Errorf("term: restore: flush: %w", ferr)
150+ case rerr != nil:
151+ return fmt.Errorf("term: restore: %w", rerr)
152+ default:
153+ return nil
154+ }
155+}
156+
157+// restoreRawLocked restores the original terminal mode if it was saved.
158+// Must be called with t.mu held.
159+func (t *Terminal) restoreRawLocked() error {
160+ if t.orig == nil {
161+ return nil
162+ }
163+ err := term.Restore(t.fd, t.orig)
164+ t.orig = nil
165+ return err
166+}
167+
168+// Size returns the current terminal dimensions.
169+func (t *Terminal) Size() (domain.Size, error) {
170+ cols, rows, err := term.GetSize(t.fd)
171+ if err != nil {
172+ return domain.Size{}, fmt.Errorf("term: get size: %w", err)
173+ }
174+ return domain.Size{Cols: cols, Rows: rows}, nil
175+}
176+
177+// ResizeEvents returns a channel of coalesced terminal sizes, one per
178+// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
179+// subsequent calls return the same channel. The channel is closed once
180+// restore (returned by EnterRaw) runs.
181+func (t *Terminal) ResizeEvents() <-chan domain.Size {
182+ t.resizeStart.Do(func() {
183+ t.resizeCh = make(chan domain.Size)
184+ t.resizeQuit = make(chan struct{})
185+ t.sigCh = make(chan os.Signal, 1)
186+ signal.Notify(t.sigCh, syscall.SIGWINCH)
187+
188+ t.resizeWG.Add(1)
189+ go func() {
190+ defer t.resizeWG.Done()
191+ defer signal.Stop(t.sigCh)
192+ resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)
193+ }()
194+ })
195+ return t.resizeCh
196+}
197+
198+// stopResize stops the SIGWINCH watcher goroutine (if one was started)
199+// and waits for it to exit, so its channel is guaranteed closed before
200+// stopResize returns.
201+func (t *Terminal) stopResize() {
202+ t.resizeStop.Do(func() {
203+ if t.resizeQuit != nil {
204+ close(t.resizeQuit)
205+ }
206+ })
207+ t.resizeWG.Wait()
208+}
209+
210+// In returns the reader for the controlling terminal's input.
211+func (t *Terminal) In() io.Reader {
212+ return t.in
213+}
214+
215+// Out returns the batched writer for the controlling terminal's output.
216+// Call Flush to push buffered bytes to the underlying tty.
217+func (t *Terminal) Out() io.Writer {
218+ return t.bw
219+}
220+
221+// Flush writes any buffered output to the terminal device.
222+func (t *Terminal) Flush() error {
223+ return t.bw.Flush()
224+}
@@ -0,0 +1,224 @@
1+// Package term adapts vev's ports to terminal I/O implementations.
2+package term
3+
4+import (
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/signal"
9+ "sync"
10+ "syscall"
11+
12+ "golang.org/x/term"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+// Compile-time check that Terminal satisfies ports.Terminal.
19+var _ ports.Terminal = (*Terminal)(nil)
20+
21+// Escape sequences for alt-screen and cursor visibility control. All
22+// emissions go through the batched writer and are explicitly flushed.
23+const (
24+ altScreenEnter = "\x1b[?1049h"
25+ altScreenExit = "\x1b[?1049l"
26+ cursorHide = "\x1b[?25l"
27+ cursorShow = "\x1b[?25h"
28+)
29+
30+// bufSize is the batched writer's buffer capacity.
31+const bufSize = 64 * 1024
32+
33+// Terminal implements ports.Terminal for the client-side controlling
34+// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
35+// batched stdout writer.
36+//
37+// Zero value is not usable; use New or NewWithFiles.
38+type Terminal struct {
39+ in *os.File
40+ out *os.File
41+ fd int
42+ bw *batchWriter
43+
44+ mu sync.Mutex
45+ orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
46+ entered bool // true between a successful EnterRaw and its restore
47+ rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
48+ restoreFn func() error // the single idempotent restore closure for the current session
49+
50+ resizeStart sync.Once
51+ resizeStop sync.Once
52+ resizeCh chan domain.Size
53+ resizeQuit chan struct{}
54+ sigCh chan os.Signal
55+ resizeWG sync.WaitGroup
56+}
57+
58+// New creates a Terminal backed by os.Stdin and os.Stdout.
59+func New() *Terminal {
60+ return NewWithFiles(os.Stdin, os.Stdout)
61+}
62+
63+// NewWithFiles creates a Terminal backed by the given tty files. It
64+// exists so tests can inject a PTY pair (or a plain pipe, for the
65+// non-tty paths) in place of the process's real stdin/stdout.
66+func NewWithFiles(in, out *os.File) *Terminal {
67+ return &Terminal{
68+ in: in,
69+ out: out,
70+ fd: int(in.Fd()),
71+ bw: newBatchWriter(out, bufSize),
72+ }
73+}
74+
75+// EnterRaw puts the terminal into raw mode (when the input fd is
76+// actually a tty; otherwise the raw-mode syscalls are skipped and
77+// recorded, not treated as failure), enters the alt screen, and hides
78+// the cursor. The returned restore func idempotently reverses all of
79+// this; it is safe to call multiple times.
80+func (t *Terminal) EnterRaw() (func() error, error) {
81+ t.mu.Lock()
82+ defer t.mu.Unlock()
83+
84+ if t.entered {
85+ return t.restoreFn, nil
86+ }
87+
88+ var old *term.State
89+ if term.IsTerminal(t.fd) {
90+ s, err := term.MakeRaw(t.fd)
91+ if err != nil {
92+ return nil, fmt.Errorf("term: make raw: %w", err)
93+ }
94+ old = s
95+ t.rawSkipped = false
96+ } else {
97+ t.rawSkipped = true
98+ }
99+ t.orig = old
100+
101+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil {
102+ t.restoreRawLocked()
103+ return nil, fmt.Errorf("term: enter alt screen: %w", err)
104+ }
105+ if err := t.bw.Flush(); err != nil {
106+ t.restoreRawLocked()
107+ return nil, fmt.Errorf("term: enter alt screen: %w", err)
108+ }
109+
110+ t.entered = true
111+ t.restoreFn = t.makeRestoreLocked()
112+ return t.restoreFn, nil
113+}
114+
115+// makeRestoreLocked returns an idempotent restore closure for the
116+// current EnterRaw "session". Must be called with t.mu held.
117+func (t *Terminal) makeRestoreLocked() func() error {
118+ var once sync.Once
119+ var err error
120+ return func() error {
121+ once.Do(func() {
122+ err = t.restore()
123+ })
124+ return err
125+ }
126+}
127+
128+// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
129+// restores the original terminal mode (if raw mode was actually
130+// entered), and stops the resize watcher, closing its channel.
131+func (t *Terminal) restore() error {
132+ t.mu.Lock()
133+ defer t.mu.Unlock()
134+
135+ _, werr := t.bw.WriteString(cursorShow + altScreenExit)
136+ ferr := t.bw.Flush()
137+
138+ rerr := t.restoreRawLocked()
139+
140+ t.entered = false
141+ t.restoreFn = nil
142+
143+ t.stopResize()
144+
145+ switch {
146+ case werr != nil:
147+ return fmt.Errorf("term: restore: write: %w", werr)
148+ case ferr != nil:
149+ return fmt.Errorf("term: restore: flush: %w", ferr)
150+ case rerr != nil:
151+ return fmt.Errorf("term: restore: %w", rerr)
152+ default:
153+ return nil
154+ }
155+}
156+
157+// restoreRawLocked restores the original terminal mode if it was saved.
158+// Must be called with t.mu held.
159+func (t *Terminal) restoreRawLocked() error {
160+ if t.orig == nil {
161+ return nil
162+ }
163+ err := term.Restore(t.fd, t.orig)
164+ t.orig = nil
165+ return err
166+}
167+
168+// Size returns the current terminal dimensions.
169+func (t *Terminal) Size() (domain.Size, error) {
170+ cols, rows, err := term.GetSize(t.fd)
171+ if err != nil {
172+ return domain.Size{}, fmt.Errorf("term: get size: %w", err)
173+ }
174+ return domain.Size{Cols: cols, Rows: rows}, nil
175+}
176+
177+// ResizeEvents returns a channel of coalesced terminal sizes, one per
178+// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
179+// subsequent calls return the same channel. The channel is closed once
180+// restore (returned by EnterRaw) runs.
181+func (t *Terminal) ResizeEvents() <-chan domain.Size {
182+ t.resizeStart.Do(func() {
183+ t.resizeCh = make(chan domain.Size)
184+ t.resizeQuit = make(chan struct{})
185+ t.sigCh = make(chan os.Signal, 1)
186+ signal.Notify(t.sigCh, syscall.SIGWINCH)
187+
188+ t.resizeWG.Add(1)
189+ go func() {
190+ defer t.resizeWG.Done()
191+ defer signal.Stop(t.sigCh)
192+ resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)
193+ }()
194+ })
195+ return t.resizeCh
196+}
197+
198+// stopResize stops the SIGWINCH watcher goroutine (if one was started)
199+// and waits for it to exit, so its channel is guaranteed closed before
200+// stopResize returns.
201+func (t *Terminal) stopResize() {
202+ t.resizeStop.Do(func() {
203+ if t.resizeQuit != nil {
204+ close(t.resizeQuit)
205+ }
206+ })
207+ t.resizeWG.Wait()
208+}
209+
210+// In returns the reader for the controlling terminal's input.
211+func (t *Terminal) In() io.Reader {
212+ return t.in
213+}
214+
215+// Out returns the batched writer for the controlling terminal's output.
216+// Call Flush to push buffered bytes to the underlying tty.
217+func (t *Terminal) Out() io.Writer {
218+ return t.bw
219+}
220+
221+// Flush writes any buffered output to the terminal device.
222+func (t *Terminal) Flush() error {
223+ return t.bw.Flush()
224+}
A internal/adapters/term/terminal_test.go +198 -0
@@ -0,0 +1,198 @@
1+package term
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "testing"
8+ "time"
9+)
10+
11+// pipePair returns an os.Pipe and a goroutine that continuously copies
12+// everything written to the write end into captured, closing done once
13+// the write end is closed (EOF).
14+func pipeCapture(t *testing.T) (r, w *os.File, captured *bytes.Buffer, done chan struct{}) {
15+ t.Helper()
16+ r, w, err := os.Pipe()
17+ if err != nil {
18+ t.Fatalf("os.Pipe: %v", err)
19+ }
20+ captured = &bytes.Buffer{}
21+ done = make(chan struct{})
22+ go func() {
23+ _, _ = io.Copy(captured, r)
24+ close(done)
25+ }()
26+ return r, w, captured, done
27+}
28+
29+func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
30+ inR, inW, err := os.Pipe()
31+ if err != nil {
32+ t.Fatalf("os.Pipe(in): %v", err)
33+ }
34+ defer inW.Close()
35+ defer inR.Close()
36+
37+ outR, outW, captured, done := pipeCapture(t)
38+ defer outR.Close()
39+
40+ tm := NewWithFiles(inR, outW)
41+
42+ restore, err := tm.EnterRaw()
43+ if err != nil {
44+ t.Fatalf("EnterRaw: %v", err)
45+ }
46+ if !tm.rawSkipped {
47+ t.Fatalf("expected rawSkipped=true for a non-tty fd")
48+ }
49+
50+ if err := restore(); err != nil {
51+ t.Fatalf("restore: %v", err)
52+ }
53+ // Idempotent: calling again must not error or duplicate output.
54+ if err := restore(); err != nil {
55+ t.Fatalf("second restore call: %v", err)
56+ }
57+
58+ outW.Close()
59+ <-done
60+
61+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
62+ if got := captured.String(); got != want {
63+ t.Fatalf("captured escapes = %q, want %q", got, want)
64+ }
65+}
66+
67+func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
68+ inR, inW, err := os.Pipe()
69+ if err != nil {
70+ t.Fatalf("os.Pipe(in): %v", err)
71+ }
72+ defer inW.Close()
73+ defer inR.Close()
74+
75+ outR, outW, captured, done := pipeCapture(t)
76+ defer outR.Close()
77+
78+ tm := NewWithFiles(inR, outW)
79+
80+ restore1, err := tm.EnterRaw()
81+ if err != nil {
82+ t.Fatalf("EnterRaw #1: %v", err)
83+ }
84+ restore2, err := tm.EnterRaw()
85+ if err != nil {
86+ t.Fatalf("EnterRaw #2: %v", err)
87+ }
88+
89+ if err := restore1(); err != nil {
90+ t.Fatalf("restore1: %v", err)
91+ }
92+ if err := restore2(); err != nil {
93+ t.Fatalf("restore2: %v", err)
94+ }
95+
96+ outW.Close()
97+ <-done
98+
99+ // Alt-screen/cursor escapes must appear exactly once for enter and
100+ // once for exit, regardless of how many times EnterRaw/restore were
101+ // called.
102+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
103+ if got := captured.String(); got != want {
104+ t.Fatalf("captured escapes = %q, want %q", got, want)
105+ }
106+}
107+
108+func TestTerminal_InOut(t *testing.T) {
109+ inR, inW, err := os.Pipe()
110+ if err != nil {
111+ t.Fatalf("os.Pipe(in): %v", err)
112+ }
113+ defer inW.Close()
114+ defer inR.Close()
115+
116+ outR, outW, captured, done := pipeCapture(t)
117+ defer outR.Close()
118+
119+ tm := NewWithFiles(inR, outW)
120+
121+ if tm.In() == nil {
122+ t.Fatalf("In() = nil")
123+ }
124+
125+ if _, err := tm.Out().Write([]byte("payload")); err != nil {
126+ t.Fatalf("Out().Write: %v", err)
127+ }
128+ if err := tm.Flush(); err != nil {
129+ t.Fatalf("Flush: %v", err)
130+ }
131+
132+ outW.Close()
133+ <-done
134+
135+ if got := captured.String(); got != "payload" {
136+ t.Fatalf("captured = %q, want %q", got, "payload")
137+ }
138+}
139+
140+func TestTerminal_ResizeEvents_ReturnsSameChannelAndClosesOnRestore(t *testing.T) {
141+ inR, inW, err := os.Pipe()
142+ if err != nil {
143+ t.Fatalf("os.Pipe(in): %v", err)
144+ }
145+ defer inW.Close()
146+ defer inR.Close()
147+
148+ outR, outW, _, done := pipeCapture(t)
149+ defer outR.Close()
150+
151+ tm := NewWithFiles(inR, outW)
152+
153+ restore, err := tm.EnterRaw()
154+ if err != nil {
155+ t.Fatalf("EnterRaw: %v", err)
156+ }
157+
158+ ch1 := tm.ResizeEvents()
159+ ch2 := tm.ResizeEvents()
160+ if ch1 != ch2 {
161+ t.Fatalf("ResizeEvents() returned different channels across calls")
162+ }
163+
164+ if err := restore(); err != nil {
165+ t.Fatalf("restore: %v", err)
166+ }
167+
168+ select {
169+ case _, ok := <-ch1:
170+ if ok {
171+ t.Fatalf("expected resize channel to be closed after restore")
172+ }
173+ case <-time.After(2 * time.Second):
174+ t.Fatalf("resize channel was not closed after restore")
175+ }
176+
177+ outW.Close()
178+ <-done
179+}
180+
181+func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
182+ inR, inW, err := os.Pipe()
183+ if err != nil {
184+ t.Fatalf("os.Pipe(in): %v", err)
185+ }
186+ defer inW.Close()
187+ defer inR.Close()
188+
189+ outR, outW, _, _ := pipeCapture(t)
190+ defer outR.Close()
191+ defer outW.Close()
192+
193+ tm := NewWithFiles(inR, outW)
194+
195+ if _, err := tm.Size(); err == nil {
196+ t.Fatalf("expected Size() to error on a non-tty fd")
197+ }
198+}
@@ -0,0 +1,198 @@
1+package term
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "testing"
8+ "time"
9+)
10+
11+// pipePair returns an os.Pipe and a goroutine that continuously copies
12+// everything written to the write end into captured, closing done once
13+// the write end is closed (EOF).
14+func pipeCapture(t *testing.T) (r, w *os.File, captured *bytes.Buffer, done chan struct{}) {
15+ t.Helper()
16+ r, w, err := os.Pipe()
17+ if err != nil {
18+ t.Fatalf("os.Pipe: %v", err)
19+ }
20+ captured = &bytes.Buffer{}
21+ done = make(chan struct{})
22+ go func() {
23+ _, _ = io.Copy(captured, r)
24+ close(done)
25+ }()
26+ return r, w, captured, done
27+}
28+
29+func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
30+ inR, inW, err := os.Pipe()
31+ if err != nil {
32+ t.Fatalf("os.Pipe(in): %v", err)
33+ }
34+ defer inW.Close()
35+ defer inR.Close()
36+
37+ outR, outW, captured, done := pipeCapture(t)
38+ defer outR.Close()
39+
40+ tm := NewWithFiles(inR, outW)
41+
42+ restore, err := tm.EnterRaw()
43+ if err != nil {
44+ t.Fatalf("EnterRaw: %v", err)
45+ }
46+ if !tm.rawSkipped {
47+ t.Fatalf("expected rawSkipped=true for a non-tty fd")
48+ }
49+
50+ if err := restore(); err != nil {
51+ t.Fatalf("restore: %v", err)
52+ }
53+ // Idempotent: calling again must not error or duplicate output.
54+ if err := restore(); err != nil {
55+ t.Fatalf("second restore call: %v", err)
56+ }
57+
58+ outW.Close()
59+ <-done
60+
61+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
62+ if got := captured.String(); got != want {
63+ t.Fatalf("captured escapes = %q, want %q", got, want)
64+ }
65+}
66+
67+func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
68+ inR, inW, err := os.Pipe()
69+ if err != nil {
70+ t.Fatalf("os.Pipe(in): %v", err)
71+ }
72+ defer inW.Close()
73+ defer inR.Close()
74+
75+ outR, outW, captured, done := pipeCapture(t)
76+ defer outR.Close()
77+
78+ tm := NewWithFiles(inR, outW)
79+
80+ restore1, err := tm.EnterRaw()
81+ if err != nil {
82+ t.Fatalf("EnterRaw #1: %v", err)
83+ }
84+ restore2, err := tm.EnterRaw()
85+ if err != nil {
86+ t.Fatalf("EnterRaw #2: %v", err)
87+ }
88+
89+ if err := restore1(); err != nil {
90+ t.Fatalf("restore1: %v", err)
91+ }
92+ if err := restore2(); err != nil {
93+ t.Fatalf("restore2: %v", err)
94+ }
95+
96+ outW.Close()
97+ <-done
98+
99+ // Alt-screen/cursor escapes must appear exactly once for enter and
100+ // once for exit, regardless of how many times EnterRaw/restore were
101+ // called.
102+ want := altScreenEnter + cursorHide + cursorShow + altScreenExit
103+ if got := captured.String(); got != want {
104+ t.Fatalf("captured escapes = %q, want %q", got, want)
105+ }
106+}
107+
108+func TestTerminal_InOut(t *testing.T) {
109+ inR, inW, err := os.Pipe()
110+ if err != nil {
111+ t.Fatalf("os.Pipe(in): %v", err)
112+ }
113+ defer inW.Close()
114+ defer inR.Close()
115+
116+ outR, outW, captured, done := pipeCapture(t)
117+ defer outR.Close()
118+
119+ tm := NewWithFiles(inR, outW)
120+
121+ if tm.In() == nil {
122+ t.Fatalf("In() = nil")
123+ }
124+
125+ if _, err := tm.Out().Write([]byte("payload")); err != nil {
126+ t.Fatalf("Out().Write: %v", err)
127+ }
128+ if err := tm.Flush(); err != nil {
129+ t.Fatalf("Flush: %v", err)
130+ }
131+
132+ outW.Close()
133+ <-done
134+
135+ if got := captured.String(); got != "payload" {
136+ t.Fatalf("captured = %q, want %q", got, "payload")
137+ }
138+}
139+
140+func TestTerminal_ResizeEvents_ReturnsSameChannelAndClosesOnRestore(t *testing.T) {
141+ inR, inW, err := os.Pipe()
142+ if err != nil {
143+ t.Fatalf("os.Pipe(in): %v", err)
144+ }
145+ defer inW.Close()
146+ defer inR.Close()
147+
148+ outR, outW, _, done := pipeCapture(t)
149+ defer outR.Close()
150+
151+ tm := NewWithFiles(inR, outW)
152+
153+ restore, err := tm.EnterRaw()
154+ if err != nil {
155+ t.Fatalf("EnterRaw: %v", err)
156+ }
157+
158+ ch1 := tm.ResizeEvents()
159+ ch2 := tm.ResizeEvents()
160+ if ch1 != ch2 {
161+ t.Fatalf("ResizeEvents() returned different channels across calls")
162+ }
163+
164+ if err := restore(); err != nil {
165+ t.Fatalf("restore: %v", err)
166+ }
167+
168+ select {
169+ case _, ok := <-ch1:
170+ if ok {
171+ t.Fatalf("expected resize channel to be closed after restore")
172+ }
173+ case <-time.After(2 * time.Second):
174+ t.Fatalf("resize channel was not closed after restore")
175+ }
176+
177+ outW.Close()
178+ <-done
179+}
180+
181+func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
182+ inR, inW, err := os.Pipe()
183+ if err != nil {
184+ t.Fatalf("os.Pipe(in): %v", err)
185+ }
186+ defer inW.Close()
187+ defer inR.Close()
188+
189+ outR, outW, _, _ := pipeCapture(t)
190+ defer outR.Close()
191+ defer outW.Close()
192+
193+ tm := NewWithFiles(inR, outW)
194+
195+ if _, err := tm.Size(); err == nil {
196+ t.Fatalf("expected Size() to error on a non-tty fd")
197+ }
198+}

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 2

Tags 13

↑↓ moveenter openesc close