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