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}