feat(domain,ports): core types, port interfaces, IPC frame surface
Adds internal/domain (Size, Rect, Session, Window) and internal/ports (PTY, PTYFactory, Terminal, Transport, Listener, Clock, Timer plus the Frame/MsgType IPC surface) that the pty, ipc, and term adapters will implement in parallel.
- commit
- 621dedb6bc639fe3564bed3ee7fff4667746fb9b
- author
- brice · · 2026-07-02 10:56 UTC
- parent
- 538f8cc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +189 -0
A internal/domain/types.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // Size is a terminal dimension in columns and rows. | |
| 4 | + | type Size struct { | |
| 5 | + | Cols, Rows int | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | // Valid reports whether sz has strictly positive columns and rows. | |
| 9 | + | func (sz Size) Valid() bool { | |
| 10 | + | return sz.Cols > 0 && sz.Rows > 0 | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // Rect is an axis-aligned rectangle in cell coordinates. | |
| 14 | + | type Rect struct { | |
| 15 | + | X, Y, Width, Height int | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // SessionID uniquely identifies a session. | |
| 19 | + | type SessionID string | |
| 20 | + | ||
| 21 | + | // WindowID uniquely identifies a window within a session. | |
| 22 | + | type WindowID string | |
| 23 | + | ||
| 24 | + | // Window is a single window (tab) within a session. | |
| 25 | + | type Window struct { | |
| 26 | + | ID WindowID | |
| 27 | + | Index int // 0-based; drives Alt+1..9 and status-bar order | |
| 28 | + | Name string | |
| 29 | + | Active bool | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Session is a persistent multiplexer session containing one or more windows. | |
| 33 | + | type Session struct { | |
| 34 | + | ID SessionID | |
| 35 | + | Name string // display name; numbered "0","1"... when ephemeral | |
| 36 | + | Ephemeral bool | |
| 37 | + | Windows []Window | |
| 38 | + | ActiveWin WindowID | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | // Size is a terminal dimension in columns and rows. | |||
| 4 | + | type Size struct { | |||
| 5 | + | Cols, Rows int | |||
| 6 | + | } | |||
| 7 | + | ||||
| 8 | + | // Valid reports whether sz has strictly positive columns and rows. | |||
| 9 | + | func (sz Size) Valid() bool { | |||
| 10 | + | return sz.Cols > 0 && sz.Rows > 0 | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | // Rect is an axis-aligned rectangle in cell coordinates. | |||
| 14 | + | type Rect struct { | |||
| 15 | + | X, Y, Width, Height int | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // SessionID uniquely identifies a session. | |||
| 19 | + | type SessionID string | |||
| 20 | + | ||||
| 21 | + | // WindowID uniquely identifies a window within a session. | |||
| 22 | + | type WindowID string | |||
| 23 | + | ||||
| 24 | + | // Window is a single window (tab) within a session. | |||
| 25 | + | type Window struct { | |||
| 26 | + | ID WindowID | |||
| 27 | + | Index int // 0-based; drives Alt+1..9 and status-bar order | |||
| 28 | + | Name string | |||
| 29 | + | Active bool | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Session is a persistent multiplexer session containing one or more windows. | |||
| 33 | + | type Session struct { | |||
| 34 | + | ID SessionID | |||
| 35 | + | Name string // display name; numbered "0","1"... when ephemeral | |||
| 36 | + | Ephemeral bool | |||
| 37 | + | Windows []Window | |||
| 38 | + | ActiveWin WindowID | |||
| 39 | + | } | |||
A internal/domain/types_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSizeValid(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | sz Size | |
| 9 | + | want bool | |
| 10 | + | }{ | |
| 11 | + | {"positive", Size{Cols: 80, Rows: 24}, true}, | |
| 12 | + | {"zero cols", Size{Cols: 0, Rows: 24}, false}, | |
| 13 | + | {"zero rows", Size{Cols: 80, Rows: 0}, false}, | |
| 14 | + | {"zero both", Size{Cols: 0, Rows: 0}, false}, | |
| 15 | + | {"negative cols", Size{Cols: -1, Rows: 24}, false}, | |
| 16 | + | {"negative rows", Size{Cols: 80, Rows: -1}, false}, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | for _, tt := range tests { | |
| 20 | + | t.Run(tt.name, func(t *testing.T) { | |
| 21 | + | if got := tt.sz.Valid(); got != tt.want { | |
| 22 | + | t.Errorf("Size%+v.Valid() = %v, want %v", tt.sz, got, tt.want) | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSizeValid(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | sz Size | |||
| 9 | + | want bool | |||
| 10 | + | }{ | |||
| 11 | + | {"positive", Size{Cols: 80, Rows: 24}, true}, | |||
| 12 | + | {"zero cols", Size{Cols: 0, Rows: 24}, false}, | |||
| 13 | + | {"zero rows", Size{Cols: 80, Rows: 0}, false}, | |||
| 14 | + | {"zero both", Size{Cols: 0, Rows: 0}, false}, | |||
| 15 | + | {"negative cols", Size{Cols: -1, Rows: 24}, false}, | |||
| 16 | + | {"negative rows", Size{Cols: 80, Rows: -1}, false}, | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | for _, tt := range tests { | |||
| 20 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 21 | + | if got := tt.sz.Valid(); got != tt.want { | |||
| 22 | + | t.Errorf("Size%+v.Valid() = %v, want %v", tt.sz, got, tt.want) | |||
| 23 | + | } | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
M internal/ports/doc.go +2 -0
| @@ -1,2 +1,4 @@ | |||
| 1 | 1 | // Package ports defines the interfaces that decouple vev's core from its adapters. | |
| 2 | 2 | package ports | |
| 3 | + | ||
| 4 | + | //go:generate mockery | |
| @@ -1,2 +1,4 @@ | |||||
| 1 | // Package ports defines the interfaces that decouple vev's core from its adapters. | 1 | // Package ports defines the interfaces that decouple vev's core from its adapters. | ||
| 2 | package ports | 2 | package ports | ||
| 3 | + | ||||
| 4 | + | //go:generate mockery | |||
A internal/ports/frame.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | + | const ProtocolVersion uint16 = 1 | |
| 5 | + | ||
| 6 | + | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | + | type MsgType uint8 | |
| 8 | + | ||
| 9 | + | // Frame message types. Client-originated messages are numbered from 1, | |
| 10 | + | // server-originated messages from 16, leaving room for growth in each band. | |
| 11 | + | const ( | |
| 12 | + | MsgHello MsgType = 1 | |
| 13 | + | MsgInput MsgType = 2 | |
| 14 | + | MsgResize MsgType = 3 | |
| 15 | + | MsgDetach MsgType = 4 | |
| 16 | + | MsgPing MsgType = 5 | |
| 17 | + | ||
| 18 | + | MsgWelcome MsgType = 16 | |
| 19 | + | MsgError MsgType = 17 | |
| 20 | + | MsgOutput MsgType = 18 | |
| 21 | + | MsgDetached MsgType = 19 | |
| 22 | + | MsgPong MsgType = 20 | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| 26 | + | // message. It lives in ports (rather than an ipc adapter) so usecases and | |
| 27 | + | // adapters can share it without either depending on a concrete transport | |
| 28 | + | // implementation. | |
| 29 | + | type Frame struct { | |
| 30 | + | Type MsgType | |
| 31 | + | Payload []byte | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // ProtocolVersion is the current vev IPC wire protocol version. | |||
| 4 | + | const ProtocolVersion uint16 = 1 | |||
| 5 | + | ||||
| 6 | + | // MsgType identifies the kind of payload carried by a Frame. | |||
| 7 | + | type MsgType uint8 | |||
| 8 | + | ||||
| 9 | + | // Frame message types. Client-originated messages are numbered from 1, | |||
| 10 | + | // server-originated messages from 16, leaving room for growth in each band. | |||
| 11 | + | const ( | |||
| 12 | + | MsgHello MsgType = 1 | |||
| 13 | + | MsgInput MsgType = 2 | |||
| 14 | + | MsgResize MsgType = 3 | |||
| 15 | + | MsgDetach MsgType = 4 | |||
| 16 | + | MsgPing MsgType = 5 | |||
| 17 | + | ||||
| 18 | + | MsgWelcome MsgType = 16 | |||
| 19 | + | MsgError MsgType = 17 | |||
| 20 | + | MsgOutput MsgType = 18 | |||
| 21 | + | MsgDetached MsgType = 19 | |||
| 22 | + | MsgPong MsgType = 20 | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |||
| 26 | + | // message. It lives in ports (rather than an ipc adapter) so usecases and | |||
| 27 | + | // adapters can share it without either depending on a concrete transport | |||
| 28 | + | // implementation. | |||
| 29 | + | type Frame struct { | |||
| 30 | + | Type MsgType | |||
| 31 | + | Payload []byte | |||
| 32 | + | } | |||
A internal/ports/frame_test.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestMsgTypeUnique(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | typ MsgType | |
| 9 | + | }{ | |
| 10 | + | {"MsgHello", MsgHello}, | |
| 11 | + | {"MsgInput", MsgInput}, | |
| 12 | + | {"MsgResize", MsgResize}, | |
| 13 | + | {"MsgDetach", MsgDetach}, | |
| 14 | + | {"MsgPing", MsgPing}, | |
| 15 | + | {"MsgWelcome", MsgWelcome}, | |
| 16 | + | {"MsgError", MsgError}, | |
| 17 | + | {"MsgOutput", MsgOutput}, | |
| 18 | + | {"MsgDetached", MsgDetached}, | |
| 19 | + | {"MsgPong", MsgPong}, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | seen := make(map[MsgType]string, len(tests)) | |
| 23 | + | for _, tt := range tests { | |
| 24 | + | if prev, ok := seen[tt.typ]; ok { | |
| 25 | + | t.Errorf("MsgType value %d used by both %s and %s", tt.typ, prev, tt.name) | |
| 26 | + | } | |
| 27 | + | seen[tt.typ] = tt.name | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | if len(seen) != len(tests) { | |
| 31 | + | t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen)) | |
| 32 | + | } | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestMsgTypeUnique(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | typ MsgType | |||
| 9 | + | }{ | |||
| 10 | + | {"MsgHello", MsgHello}, | |||
| 11 | + | {"MsgInput", MsgInput}, | |||
| 12 | + | {"MsgResize", MsgResize}, | |||
| 13 | + | {"MsgDetach", MsgDetach}, | |||
| 14 | + | {"MsgPing", MsgPing}, | |||
| 15 | + | {"MsgWelcome", MsgWelcome}, | |||
| 16 | + | {"MsgError", MsgError}, | |||
| 17 | + | {"MsgOutput", MsgOutput}, | |||
| 18 | + | {"MsgDetached", MsgDetached}, | |||
| 19 | + | {"MsgPong", MsgPong}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | seen := make(map[MsgType]string, len(tests)) | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | if prev, ok := seen[tt.typ]; ok { | |||
| 25 | + | t.Errorf("MsgType value %d used by both %s and %s", tt.typ, prev, tt.name) | |||
| 26 | + | } | |||
| 27 | + | seen[tt.typ] = tt.name | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | if len(seen) != len(tests) { | |||
| 31 | + | t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen)) | |||
| 32 | + | } | |||
| 33 | + | } | |||
A internal/ports/ports.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // PTY is a running pseudo-terminal-backed child process. | |
| 11 | + | type PTY interface { | |
| 12 | + | io.ReadWriteCloser // Read = child output, Write = child input | |
| 13 | + | Resize(sz domain.Size) error | |
| 14 | + | Pid() int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| 18 | + | type PTYFactory interface { | |
| 19 | + | Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Terminal is the CLIENT-side controlling terminal. | |
| 23 | + | type Terminal interface { | |
| 24 | + | EnterRaw() (restore func() error, err error) | |
| 25 | + | Size() (domain.Size, error) | |
| 26 | + | ResizeEvents() <-chan domain.Size | |
| 27 | + | In() io.Reader | |
| 28 | + | Out() io.Writer | |
| 29 | + | Flush() error | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Transport is a framed message channel over a single connection. | |
| 33 | + | type Transport interface { | |
| 34 | + | Send(Frame) error | |
| 35 | + | Recv() (Frame, error) // blocking; io.EOF on close | |
| 36 | + | Close() error | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Listener accepts incoming Transport connections. | |
| 40 | + | type Listener interface { | |
| 41 | + | Accept() (Transport, error) | |
| 42 | + | Close() error | |
| 43 | + | Addr() string | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Clock abstracts time so usecases can be tested without real delays. | |
| 47 | + | type Clock interface { | |
| 48 | + | Now() time.Time | |
| 49 | + | NewTimer(d time.Duration) Timer | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Timer abstracts a cancellable, resettable time.Timer. | |
| 53 | + | type Timer interface { | |
| 54 | + | C() <-chan time.Time | |
| 55 | + | Reset(d time.Duration) bool | |
| 56 | + | Stop() bool | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // PTY is a running pseudo-terminal-backed child process. | |||
| 11 | + | type PTY interface { | |||
| 12 | + | io.ReadWriteCloser // Read = child output, Write = child input | |||
| 13 | + | Resize(sz domain.Size) error | |||
| 14 | + | Pid() int | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |||
| 18 | + | type PTYFactory interface { | |||
| 19 | + | Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Terminal is the CLIENT-side controlling terminal. | |||
| 23 | + | type Terminal interface { | |||
| 24 | + | EnterRaw() (restore func() error, err error) | |||
| 25 | + | Size() (domain.Size, error) | |||
| 26 | + | ResizeEvents() <-chan domain.Size | |||
| 27 | + | In() io.Reader | |||
| 28 | + | Out() io.Writer | |||
| 29 | + | Flush() error | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Transport is a framed message channel over a single connection. | |||
| 33 | + | type Transport interface { | |||
| 34 | + | Send(Frame) error | |||
| 35 | + | Recv() (Frame, error) // blocking; io.EOF on close | |||
| 36 | + | Close() error | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Listener accepts incoming Transport connections. | |||
| 40 | + | type Listener interface { | |||
| 41 | + | Accept() (Transport, error) | |||
| 42 | + | Close() error | |||
| 43 | + | Addr() string | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Clock abstracts time so usecases can be tested without real delays. | |||
| 47 | + | type Clock interface { | |||
| 48 | + | Now() time.Time | |||
| 49 | + | NewTimer(d time.Duration) Timer | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Timer abstracts a cancellable, resettable time.Timer. | |||
| 53 | + | type Timer interface { | |||
| 54 | + | C() <-chan time.Time | |||
| 55 | + | Reset(d time.Duration) bool | |||
| 56 | + | Stop() bool | |||
| 57 | + | } | |||