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}