// Package term adapts vev's ports to terminal I/O implementations. package term import ( "fmt" "io" "os" "os/signal" "sync" "syscall" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/pkg/rawterm" ) // Compile-time check that Terminal satisfies ports.Terminal. var _ ports.Terminal = (*Terminal)(nil) // bufSize is the batched writer's buffer capacity. const bufSize = 64 * 1024 // Terminal implements ports.Terminal for the client-side controlling // terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a // batched stdout writer. // // Zero value is not usable; use New or NewWithFiles. type Terminal struct { in *os.File out *os.File fd int bw *batchWriter observation ports.UIObservationSink mu sync.Mutex geometryMu sync.Mutex // serializes winsize reads with observation publication orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored) entered bool // true between a successful EnterRaw and its restore rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped restoreFn func() error // the single idempotent restore closure for the current session kittyKeys bool // kitty keyboard flags were pushed and must be popped on restore // Resize watcher state, also guarded by mu so watcher start // (ResizeEvents) and watcher stop (restore) are strictly ordered: // a restore that runs before the first ResizeEvents call must // prevent any watcher (and its signal.Notify) from ever starting. resizeCh chan domain.Geometry resizeQuit chan struct{} sigCh chan os.Signal resizeDone bool // set by stopResizeLocked; no watcher may start afterwards resizeWG sync.WaitGroup } // New creates a Terminal backed by os.Stdin and os.Stdout. func New() *Terminal { return NewWithFiles(os.Stdin, os.Stdout) } // NewWithFiles creates a Terminal backed by the given tty files. It // exists so tests can inject a PTY pair (or a plain pipe, for the // non-tty paths) in place of the process's real stdin/stdout. func NewWithFiles(in, out *os.File) *Terminal { return NewWithFilesAndObservation(in, out, nil) } // NewWithFilesAndObservation creates a terminal that mirrors successfully // emitted byte prefixes and flush boundaries to observation. func NewWithFilesAndObservation(in, out *os.File, observation ports.UIObservationSink) *Terminal { return &Terminal{ in: in, out: out, fd: int(in.Fd()), bw: newBatchWriterWithObservation(out, bufSize, observation), observation: observation, } } // EnterRaw puts the terminal into raw mode (when the input fd is // actually a tty; otherwise the raw-mode syscalls are skipped and // recorded, not treated as failure), enters the alt screen, and hides // the cursor. The returned restore func idempotently reverses all of // this; it is safe to call multiple times. func (t *Terminal) EnterRaw() (func() error, error) { t.mu.Lock() defer t.mu.Unlock() if t.entered { return t.restoreFn, nil } var old *rawterm.State if rawterm.IsTerminal(t.fd) { s, err := rawterm.MakeRaw(t.fd) if err != nil { return nil, fmt.Errorf("term: make raw: %w", err) } old = s t.rawSkipped = false } else { t.rawSkipped = true } t.orig = old if _, err := t.bw.Write(VisualEnterSequence()); err != nil { t.resetVisualModesDirect() _ = t.restoreRawLocked() return nil, fmt.Errorf("term: enter alt screen: %w", err) } if err := t.bw.Flush(); err != nil { t.resetVisualModesDirect() _ = t.restoreRawLocked() return nil, fmt.Errorf("term: enter alt screen: %w", err) } t.entered = true t.restoreFn = t.makeRestoreLocked() return t.restoreFn, nil } // EnableKittyKeyboard pushes kitty keyboard protocol flags on the alternate // screen entered by EnterRaw. The session's restore pops them. func (t *Terminal) EnableKittyKeyboard(flags int) error { t.mu.Lock() defer t.mu.Unlock() if !t.entered || t.kittyKeys { return nil } // Mark first: if any prefix reached the terminal, restore must pop. t.kittyKeys = true if _, err := t.bw.Write(KittyKeyboardPushSequence(flags)); err != nil { return fmt.Errorf("term: enable kitty keyboard: %w", err) } if err := t.bw.Flush(); err != nil { return fmt.Errorf("term: enable kitty keyboard: %w", err) } return nil } // restoreSequenceLocked returns the visual cleanup for the current session. // Must be called with t.mu held. func (t *Terminal) restoreSequenceLocked() []byte { if t.kittyKeys { return VisualRestoreSequenceWithKittyKeyboard() } return VisualRestoreSequence() } // resetVisualModesDirect writes visualRestore straight to the descriptor // instead of through the batched writer. It is the fallback for a writer that // has already failed: any prefix of the enter sequence may have reached the // terminal, and a hidden cursor, live mouse reporting or the alt screen would // outlive the process. Replaying the resets when nothing was emitted is // harmless. Best effort by construction — the descriptor is usually broken // too. Must be called with t.mu held. func (t *Terminal) resetVisualModesDirect() { if t.observation != nil { t.observation.InvalidateTerminalObservation() } _, _ = t.out.Write(t.restoreSequenceLocked()) } // makeRestoreLocked returns an idempotent restore closure for the // current EnterRaw "session". Must be called with t.mu held. func (t *Terminal) makeRestoreLocked() func() error { var once sync.Once var err error return func() error { once.Do(func() { err = t.restore() }) return err } } // restore reverses EnterRaw: it shows the cursor, exits the alt screen, // restores the original terminal mode (if raw mode was actually // entered), and stops the resize watcher, closing its channel. func (t *Terminal) restore() error { t.mu.Lock() defer t.mu.Unlock() _, werr := t.bw.Write(t.restoreSequenceLocked()) ferr := t.bw.Flush() if werr != nil || ferr != nil { t.resetVisualModesDirect() } rerr := t.restoreRawLocked() t.entered = false t.kittyKeys = false t.restoreFn = nil t.stopResizeLocked() switch { case werr != nil: return fmt.Errorf("term: restore: write: %w", werr) case ferr != nil: return fmt.Errorf("term: restore: flush: %w", ferr) case rerr != nil: return fmt.Errorf("term: restore: %w", rerr) default: return nil } } // restoreRawLocked restores the original terminal mode if it was saved. // Must be called with t.mu held. func (t *Terminal) restoreRawLocked() error { if t.orig == nil { return nil } err := rawterm.Restore(t.fd, t.orig) t.orig = nil return err } // Geometry returns the current terminal cell dimensions and optional pixel // dimensions reported by the controlling terminal. func (t *Terminal) Geometry() (domain.Geometry, error) { t.geometryMu.Lock() defer t.geometryMu.Unlock() ws, err := rawterm.GetWinsize(t.fd) if err != nil { return domain.Geometry{}, fmt.Errorf("term: get size: %w", err) } geometry := (domain.Geometry{ Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)}, PixelWidth: int(ws.Xpixel), PixelHeight: int(ws.Ypixel), }).NormalizePixels() if t.observation != nil { t.observation.ObserveTerminalResize(geometry) } return geometry, nil } // ResizeEvents returns a channel of coalesced terminal sizes, one per // SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call; // subsequent calls return the same channel. The channel is closed once // restore (returned by EnterRaw) runs. If restore has already run when // ResizeEvents is first called, no watcher is started and the returned // channel is already closed. func (t *Terminal) ResizeEvents() <-chan domain.Geometry { t.mu.Lock() defer t.mu.Unlock() if t.resizeCh != nil { return t.resizeCh } t.resizeCh = make(chan domain.Geometry) if t.resizeDone { // restore ran before the first ResizeEvents call: never start a // watcher (or a signal handler) after stop; hand back a closed // channel so a consumer selecting on it doesn't hang. close(t.resizeCh) return t.resizeCh } t.resizeQuit = make(chan struct{}) t.sigCh = make(chan os.Signal, 1) signal.Notify(t.sigCh, syscall.SIGWINCH) t.resizeWG.Go(func() { defer signal.Stop(t.sigCh) resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Geometry) }) return t.resizeCh } // stopResizeLocked stops the SIGWINCH watcher goroutine (if one was // started) and waits for it to exit, so its channel is guaranteed // closed before stopResizeLocked returns. It marks the watcher as done // so no watcher can start afterwards. Must be called with t.mu held; // the watcher goroutine never takes t.mu, so waiting under the lock // cannot deadlock. func (t *Terminal) stopResizeLocked() { if !t.resizeDone { t.resizeDone = true if t.resizeQuit != nil { close(t.resizeQuit) } } t.resizeWG.Wait() } // In returns the reader for the controlling terminal's input. func (t *Terminal) In() io.Reader { return t.in } // Out returns the batched writer for the controlling terminal's output. // Call Flush to push buffered bytes to the underlying tty. func (t *Terminal) Out() io.Writer { return t.bw } // Flush writes any buffered output to the terminal device. func (t *Terminal) Flush() error { err := t.bw.Flush() if err != nil && t.observation != nil { t.observation.InvalidateTerminalObservation() } return err }