terminal.go

  1// Package terminal provides a reusable embedded terminal component that runs
  2// commands in a PTY and renders them using a virtual terminal emulator.
  3package terminal
  4
  5import (
  6	"context"
  7	"errors"
  8	"image/color"
  9	"io"
 10	"log/slog"
 11	"os"
 12	"os/exec"
 13	"sync"
 14	"time"
 15
 16	tea "charm.land/bubbletea/v2"
 17	uv "github.com/charmbracelet/ultraviolet"
 18	"github.com/charmbracelet/x/ansi"
 19	"github.com/charmbracelet/x/vt"
 20	"github.com/charmbracelet/x/xpty"
 21)
 22
 23// ExitMsg is sent when the terminal process exits.
 24type ExitMsg struct {
 25	// Err is the error returned by the process, if any.
 26	Err error
 27}
 28
 29// OutputMsg signals that there is new output to render.
 30type OutputMsg struct{}
 31
 32// Config holds configuration for the terminal.
 33type Config struct {
 34	// Context is the context for the terminal. When cancelled, the terminal
 35	// process will be killed.
 36	Context context.Context
 37	// Cmd is the command to execute.
 38	Cmd *exec.Cmd
 39	// RefreshRate is how often to refresh the display (default: 24fps).
 40	RefreshRate time.Duration
 41}
 42
 43// DefaultRefreshRate is the default refresh rate for terminal output.
 44const DefaultRefreshRate = time.Second / 24
 45
 46// Terminal is an embedded terminal that runs a command in a PTY and renders
 47// it using a virtual terminal emulator.
 48type Terminal struct {
 49	mu sync.RWMutex
 50
 51	ctx   context.Context
 52	pty   xpty.Pty
 53	vterm *vt.Emulator
 54	cmd   *exec.Cmd
 55
 56	width       int
 57	height      int
 58	mouseMode   uv.MouseMode
 59	refreshRate time.Duration
 60
 61	started bool
 62	closed  bool
 63}
 64
 65// New creates a new Terminal with the given configuration.
 66func New(cfg Config) *Terminal {
 67	ctx := cfg.Context
 68	if ctx == nil {
 69		ctx = context.Background()
 70	}
 71
 72	refreshRate := cfg.RefreshRate
 73	if refreshRate == 0 {
 74		refreshRate = DefaultRefreshRate
 75	}
 76
 77	// Prepare the command with the provided context.
 78	var cmd *exec.Cmd
 79	if cfg.Cmd != nil {
 80		cmd = exec.CommandContext(ctx, cfg.Cmd.Path, cfg.Cmd.Args[1:]...)
 81		cmd.Dir = cfg.Cmd.Dir
 82		cmd.Env = cfg.Cmd.Env
 83		cmd.SysProcAttr = sysProcAttr()
 84	}
 85
 86	return &Terminal{
 87		ctx:         ctx,
 88		cmd:         cmd,
 89		refreshRate: refreshRate,
 90	}
 91}
 92
 93// Start initializes the PTY and starts the command.
 94func (t *Terminal) Start() error {
 95	t.mu.Lock()
 96	defer t.mu.Unlock()
 97
 98	if t.closed {
 99		return errors.New("terminal already closed")
100	}
101	if t.started {
102		return errors.New("terminal already started")
103	}
104	if t.cmd == nil {
105		return errors.New("no command specified")
106	}
107	if t.width <= 0 || t.height <= 0 {
108		return errors.New("invalid dimensions")
109	}
110
111	// Create PTY with specified dimensions.
112	pty, err := xpty.NewPty(t.width, t.height)
113	if err != nil {
114		return err
115	}
116	t.pty = pty
117
118	// Create virtual terminal emulator.
119	t.vterm = vt.NewEmulator(t.width, t.height)
120
121	// Set default colors to prevent nil pointer panics when rendering
122	// before the terminal has received content with explicit colors.
123	t.vterm.SetDefaultForegroundColor(color.White)
124	t.vterm.SetDefaultBackgroundColor(color.Black)
125
126	// Set up callbacks to track mouse mode.
127	t.setupCallbacks()
128
129	// Start the command in the PTY.
130	if err := t.pty.Start(t.cmd); err != nil {
131		t.pty.Close()
132		t.pty = nil
133		t.vterm = nil
134		return err
135	}
136
137	// Bidirectional I/O between PTY and virtual terminal.
138	go func() {
139		if _, err := io.Copy(t.pty, t.vterm); err != nil && !isExpectedIOError(err) {
140			slog.Debug("terminal vterm->pty copy error", "error", err)
141		}
142	}()
143	go func() {
144		if _, err := io.Copy(t.vterm, t.pty); err != nil && !isExpectedIOError(err) {
145			slog.Debug("terminal pty->vterm copy error", "error", err)
146		}
147	}()
148
149	t.started = true
150	return nil
151}
152
153// setupCallbacks configures vterm callbacks to track mouse mode.
154func (t *Terminal) setupCallbacks() {
155	t.vterm.SetCallbacks(vt.Callbacks{
156		EnableMode: func(mode ansi.Mode) {
157			switch mode {
158			case ansi.ModeMouseNormal:
159				t.mouseMode = uv.MouseModeClick
160			case ansi.ModeMouseButtonEvent:
161				t.mouseMode = uv.MouseModeDrag
162			case ansi.ModeMouseAnyEvent:
163				t.mouseMode = uv.MouseModeMotion
164			}
165		},
166		DisableMode: func(mode ansi.Mode) {
167			switch mode {
168			case ansi.ModeMouseNormal, ansi.ModeMouseButtonEvent, ansi.ModeMouseAnyEvent:
169				t.mouseMode = uv.MouseModeNone
170			}
171		},
172	})
173}
174
175// Resize changes the terminal dimensions.
176func (t *Terminal) Resize(width, height int) error {
177	t.mu.Lock()
178	defer t.mu.Unlock()
179
180	if t.closed {
181		return errors.New("terminal already closed")
182	}
183
184	t.width = width
185	t.height = height
186
187	if t.started {
188		if t.vterm != nil {
189			t.vterm.Resize(width, height)
190		}
191		if t.pty != nil {
192			return t.pty.Resize(width, height)
193		}
194	}
195	return nil
196}
197
198// SendText sends text input to the terminal.
199func (t *Terminal) SendText(text string) {
200	t.mu.Lock()
201	defer t.mu.Unlock()
202
203	if t.vterm != nil && t.started && !t.closed {
204		t.vterm.SendText(text)
205	}
206}
207
208// SendKey sends a key event to the terminal.
209func (t *Terminal) SendKey(key tea.KeyPressMsg) {
210	t.mu.Lock()
211	defer t.mu.Unlock()
212
213	if t.vterm != nil && t.started && !t.closed {
214		t.vterm.SendKey(vt.KeyPressEvent(key))
215	}
216}
217
218// SendPaste sends pasted content to the terminal.
219func (t *Terminal) SendPaste(content string) {
220	t.mu.Lock()
221	defer t.mu.Unlock()
222
223	if t.vterm != nil && t.started && !t.closed {
224		t.vterm.Paste(content)
225	}
226}
227
228// SendMouse sends a mouse event to the terminal.
229func (t *Terminal) SendMouse(msg tea.MouseMsg) {
230	t.mu.Lock()
231	defer t.mu.Unlock()
232
233	if t.vterm == nil || !t.started || t.closed || t.mouseMode == uv.MouseModeNone {
234		return
235	}
236
237	switch ev := msg.(type) {
238	case tea.MouseClickMsg:
239		t.vterm.SendMouse(vt.MouseClick(ev))
240	case tea.MouseReleaseMsg:
241		t.vterm.SendMouse(vt.MouseRelease(ev))
242	case tea.MouseWheelMsg:
243		t.vterm.SendMouse(vt.MouseWheel(ev))
244	case tea.MouseMotionMsg:
245		// Check mouse mode for motion events.
246		if ev.Button == tea.MouseNone && t.mouseMode != uv.MouseModeMotion {
247			return
248		}
249		if ev.Button != tea.MouseNone && t.mouseMode == uv.MouseModeClick {
250			return
251		}
252		t.vterm.SendMouse(vt.MouseMotion(ev))
253	}
254}
255
256// Render returns the current terminal content as a string with ANSI styling.
257func (t *Terminal) Render() string {
258	t.mu.RLock()
259	defer t.mu.RUnlock()
260
261	if t.vterm == nil || !t.started || t.closed {
262		return ""
263	}
264
265	return t.vterm.Render()
266}
267
268// Started returns whether the terminal has been started.
269func (t *Terminal) Started() bool {
270	t.mu.RLock()
271	defer t.mu.RUnlock()
272	return t.started
273}
274
275// Closed returns whether the terminal has been closed.
276func (t *Terminal) Closed() bool {
277	t.mu.RLock()
278	defer t.mu.RUnlock()
279	return t.closed
280}
281
282// Close stops the terminal process and cleans up resources.
283func (t *Terminal) Close() error {
284	t.mu.Lock()
285	defer t.mu.Unlock()
286
287	if t.closed {
288		return nil
289	}
290	t.closed = true
291
292	var errs []error
293
294	// Explicitly kill the process if still running.
295	if t.cmd != nil && t.cmd.Process != nil {
296		_ = t.cmd.Process.Kill()
297	}
298
299	// Close PTY.
300	if t.pty != nil {
301		if err := t.pty.Close(); err != nil {
302			errs = append(errs, err)
303		}
304		t.pty = nil
305	}
306
307	// Close virtual terminal.
308	if t.vterm != nil {
309		if err := t.vterm.Close(); err != nil {
310			errs = append(errs, err)
311		}
312		t.vterm = nil
313	}
314
315	return errors.Join(errs...)
316}
317
318// WaitCmd returns a tea.Cmd that waits for the process to exit.
319func (t *Terminal) WaitCmd() tea.Cmd {
320	return func() tea.Msg {
321		t.mu.RLock()
322		cmd := t.cmd
323		ctx := t.ctx
324		t.mu.RUnlock()
325
326		if cmd == nil || cmd.Process == nil {
327			return ExitMsg{}
328		}
329		err := xpty.WaitProcess(ctx, cmd)
330		return ExitMsg{Err: err}
331	}
332}
333
334// RefreshCmd returns a tea.Cmd that schedules a refresh.
335func (t *Terminal) RefreshCmd() tea.Cmd {
336	t.mu.RLock()
337	rate := t.refreshRate
338	closed := t.closed
339	t.mu.RUnlock()
340
341	if closed {
342		return nil
343	}
344	return tea.Tick(rate, func(time.Time) tea.Msg {
345		return OutputMsg{}
346	})
347}
348
349// PrepareCmd creates a command with the given arguments and optional
350// working directory. The context parameter controls the command's lifetime.
351func PrepareCmd(ctx context.Context, name string, args []string, workDir string, env []string) *exec.Cmd {
352	cmd := exec.CommandContext(ctx, name, args...)
353	cmd.Dir = workDir
354	if len(env) > 0 {
355		cmd.Env = append(os.Environ(), env...)
356	} else {
357		cmd.Env = os.Environ()
358	}
359	return cmd
360}
361
362// isExpectedIOError returns true for errors that are expected when the
363// terminal is closing (EOF, closed pipe, etc).
364func isExpectedIOError(err error) bool {
365	if err == nil {
366		return true
367	}
368	if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
369		return true
370	}
371	// Check for common close-related error messages.
372	msg := err.Error()
373	return errors.Is(err, context.Canceled) ||
374		msg == "file already closed" ||
375		msg == "read/write on closed pipe"
376}