feat: vt integration & lazygit

Kujtim Hoxha created

Change summary

go.mod                                                   |   5 
go.sum                                                   |   8 
internal/app/app.go                                      |   6 
internal/terminal/terminal.go                            | 376 ++++++++++
internal/terminal/terminal_unix.go                       |  15 
internal/terminal/terminal_windows.go                    |  12 
internal/tui/components/chat/editor/editor.go            |   2 
internal/tui/components/dialogs/commands/commands.go     |  20 
internal/tui/components/dialogs/lazygit/lazygit.go       | 113 +++
internal/tui/components/dialogs/termdialog/termdialog.go | 232 ++++++
internal/tui/tui.go                                      |  11 
11 files changed, 797 insertions(+), 3 deletions(-)

Detailed changes

go.mod 🔗

@@ -29,6 +29,8 @@ require (
 	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
 	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
 	github.com/charmbracelet/x/term v0.2.2
+	github.com/charmbracelet/x/vt v0.0.0-20251210182518-b3d4d1ed2373
+	github.com/charmbracelet/x/xpty v0.1.3
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/google/uuid v1.6.0
@@ -91,6 +93,8 @@ require (
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
 	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
+	github.com/charmbracelet/x/conpty v0.1.1 // indirect
+	github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
 	github.com/charmbracelet/x/etag v0.2.0 // indirect
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -98,6 +102,7 @@ require (
 	github.com/clipperhouse/displaywidth v0.6.1 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+	github.com/creack/pty v1.1.24 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect

go.sum 🔗

@@ -104,6 +104,10 @@ github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9G
 github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
 github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04=
 github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0=
+github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
+github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -120,8 +124,12 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/vt v0.0.0-20251210182518-b3d4d1ed2373 h1:F1APfQQP9Jg0atGelR6MsVZSRFmrvAGJoWnI3upcTng=
+github.com/charmbracelet/x/vt v0.0.0-20251210182518-b3d4d1ed2373/go.mod h1:1s9IpnepmjKTSAa/XMCl5lk63MyFNGMI0iB587Nh/Cs=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
+github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
 github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
 github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=

internal/app/app.go 🔗

@@ -121,6 +121,12 @@ func (app *App) Config() *config.Config {
 	return app.config
 }
 
+// Context returns the application's global context. When cancelled, all
+// child processes and goroutines should terminate.
+func (app *App) Context() context.Context {
+	return app.globalCtx
+}
+
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
 func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt string, quiet bool) error {

internal/terminal/terminal.go 🔗

@@ -0,0 +1,376 @@
+// Package terminal provides a reusable embedded terminal component that runs
+// commands in a PTY and renders them using a virtual terminal emulator.
+package terminal
+
+import (
+	"context"
+	"errors"
+	"image/color"
+	"io"
+	"log/slog"
+	"os"
+	"os/exec"
+	"sync"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/vt"
+	"github.com/charmbracelet/x/xpty"
+)
+
+// ExitMsg is sent when the terminal process exits.
+type ExitMsg struct {
+	// Err is the error returned by the process, if any.
+	Err error
+}
+
+// OutputMsg signals that there is new output to render.
+type OutputMsg struct{}
+
+// Config holds configuration for the terminal.
+type Config struct {
+	// Context is the context for the terminal. When cancelled, the terminal
+	// process will be killed.
+	Context context.Context
+	// Cmd is the command to execute.
+	Cmd *exec.Cmd
+	// RefreshRate is how often to refresh the display (default: 24fps).
+	RefreshRate time.Duration
+}
+
+// DefaultRefreshRate is the default refresh rate for terminal output.
+const DefaultRefreshRate = time.Second / 24
+
+// Terminal is an embedded terminal that runs a command in a PTY and renders
+// it using a virtual terminal emulator.
+type Terminal struct {
+	mu sync.RWMutex
+
+	ctx   context.Context
+	pty   xpty.Pty
+	vterm *vt.Emulator
+	cmd   *exec.Cmd
+
+	width       int
+	height      int
+	mouseMode   uv.MouseMode
+	refreshRate time.Duration
+
+	started bool
+	closed  bool
+}
+
+// New creates a new Terminal with the given configuration.
+func New(cfg Config) *Terminal {
+	ctx := cfg.Context
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
+	refreshRate := cfg.RefreshRate
+	if refreshRate == 0 {
+		refreshRate = DefaultRefreshRate
+	}
+
+	// Prepare the command with the provided context.
+	var cmd *exec.Cmd
+	if cfg.Cmd != nil {
+		cmd = exec.CommandContext(ctx, cfg.Cmd.Path, cfg.Cmd.Args[1:]...)
+		cmd.Dir = cfg.Cmd.Dir
+		cmd.Env = cfg.Cmd.Env
+		cmd.SysProcAttr = sysProcAttr()
+	}
+
+	return &Terminal{
+		ctx:         ctx,
+		cmd:         cmd,
+		refreshRate: refreshRate,
+	}
+}
+
+// Start initializes the PTY and starts the command.
+func (t *Terminal) Start() error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.closed {
+		return errors.New("terminal already closed")
+	}
+	if t.started {
+		return errors.New("terminal already started")
+	}
+	if t.cmd == nil {
+		return errors.New("no command specified")
+	}
+	if t.width <= 0 || t.height <= 0 {
+		return errors.New("invalid dimensions")
+	}
+
+	// Create PTY with specified dimensions.
+	pty, err := xpty.NewPty(t.width, t.height)
+	if err != nil {
+		return err
+	}
+	t.pty = pty
+
+	// Create virtual terminal emulator.
+	t.vterm = vt.NewEmulator(t.width, t.height)
+
+	// Set default colors to prevent nil pointer panics when rendering
+	// before the terminal has received content with explicit colors.
+	t.vterm.SetDefaultForegroundColor(color.White)
+	t.vterm.SetDefaultBackgroundColor(color.Black)
+
+	// Set up callbacks to track mouse mode.
+	t.setupCallbacks()
+
+	// Start the command in the PTY.
+	if err := t.pty.Start(t.cmd); err != nil {
+		t.pty.Close()
+		t.pty = nil
+		t.vterm = nil
+		return err
+	}
+
+	// Bidirectional I/O between PTY and virtual terminal.
+	go func() {
+		if _, err := io.Copy(t.pty, t.vterm); err != nil && !isExpectedIOError(err) {
+			slog.Debug("terminal vterm->pty copy error", "error", err)
+		}
+	}()
+	go func() {
+		if _, err := io.Copy(t.vterm, t.pty); err != nil && !isExpectedIOError(err) {
+			slog.Debug("terminal pty->vterm copy error", "error", err)
+		}
+	}()
+
+	t.started = true
+	return nil
+}
+
+// setupCallbacks configures vterm callbacks to track mouse mode.
+func (t *Terminal) setupCallbacks() {
+	t.vterm.SetCallbacks(vt.Callbacks{
+		EnableMode: func(mode ansi.Mode) {
+			switch mode {
+			case ansi.ModeMouseNormal:
+				t.mouseMode = uv.MouseModeClick
+			case ansi.ModeMouseButtonEvent:
+				t.mouseMode = uv.MouseModeDrag
+			case ansi.ModeMouseAnyEvent:
+				t.mouseMode = uv.MouseModeMotion
+			}
+		},
+		DisableMode: func(mode ansi.Mode) {
+			switch mode {
+			case ansi.ModeMouseNormal, ansi.ModeMouseButtonEvent, ansi.ModeMouseAnyEvent:
+				t.mouseMode = uv.MouseModeNone
+			}
+		},
+	})
+}
+
+// Resize changes the terminal dimensions.
+func (t *Terminal) Resize(width, height int) error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.closed {
+		return errors.New("terminal already closed")
+	}
+
+	t.width = width
+	t.height = height
+
+	if t.started {
+		if t.vterm != nil {
+			t.vterm.Resize(width, height)
+		}
+		if t.pty != nil {
+			return t.pty.Resize(width, height)
+		}
+	}
+	return nil
+}
+
+// SendText sends text input to the terminal.
+func (t *Terminal) SendText(text string) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.vterm != nil && t.started && !t.closed {
+		t.vterm.SendText(text)
+	}
+}
+
+// SendKey sends a key event to the terminal.
+func (t *Terminal) SendKey(key tea.KeyPressMsg) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.vterm != nil && t.started && !t.closed {
+		t.vterm.SendKey(vt.KeyPressEvent(key))
+	}
+}
+
+// SendPaste sends pasted content to the terminal.
+func (t *Terminal) SendPaste(content string) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.vterm != nil && t.started && !t.closed {
+		t.vterm.Paste(content)
+	}
+}
+
+// SendMouse sends a mouse event to the terminal.
+func (t *Terminal) SendMouse(msg tea.MouseMsg) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.vterm == nil || !t.started || t.closed || t.mouseMode == uv.MouseModeNone {
+		return
+	}
+
+	switch ev := msg.(type) {
+	case tea.MouseClickMsg:
+		t.vterm.SendMouse(vt.MouseClick(ev))
+	case tea.MouseReleaseMsg:
+		t.vterm.SendMouse(vt.MouseRelease(ev))
+	case tea.MouseWheelMsg:
+		t.vterm.SendMouse(vt.MouseWheel(ev))
+	case tea.MouseMotionMsg:
+		// Check mouse mode for motion events.
+		if ev.Button == tea.MouseNone && t.mouseMode != uv.MouseModeMotion {
+			return
+		}
+		if ev.Button != tea.MouseNone && t.mouseMode == uv.MouseModeClick {
+			return
+		}
+		t.vterm.SendMouse(vt.MouseMotion(ev))
+	}
+}
+
+// Render returns the current terminal content as a string with ANSI styling.
+func (t *Terminal) Render() string {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+
+	if t.vterm == nil || !t.started || t.closed {
+		return ""
+	}
+
+	return t.vterm.Render()
+}
+
+// Started returns whether the terminal has been started.
+func (t *Terminal) Started() bool {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	return t.started
+}
+
+// Closed returns whether the terminal has been closed.
+func (t *Terminal) Closed() bool {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	return t.closed
+}
+
+// Close stops the terminal process and cleans up resources.
+func (t *Terminal) Close() error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	if t.closed {
+		return nil
+	}
+	t.closed = true
+
+	var errs []error
+
+	// Explicitly kill the process if still running.
+	if t.cmd != nil && t.cmd.Process != nil {
+		_ = t.cmd.Process.Kill()
+	}
+
+	// Close PTY.
+	if t.pty != nil {
+		if err := t.pty.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		t.pty = nil
+	}
+
+	// Close virtual terminal.
+	if t.vterm != nil {
+		if err := t.vterm.Close(); err != nil {
+			errs = append(errs, err)
+		}
+		t.vterm = nil
+	}
+
+	return errors.Join(errs...)
+}
+
+// WaitCmd returns a tea.Cmd that waits for the process to exit.
+func (t *Terminal) WaitCmd() tea.Cmd {
+	return func() tea.Msg {
+		t.mu.RLock()
+		cmd := t.cmd
+		ctx := t.ctx
+		t.mu.RUnlock()
+
+		if cmd == nil || cmd.Process == nil {
+			return ExitMsg{}
+		}
+		err := xpty.WaitProcess(ctx, cmd)
+		return ExitMsg{Err: err}
+	}
+}
+
+// RefreshCmd returns a tea.Cmd that schedules a refresh.
+func (t *Terminal) RefreshCmd() tea.Cmd {
+	t.mu.RLock()
+	rate := t.refreshRate
+	closed := t.closed
+	t.mu.RUnlock()
+
+	if closed {
+		return nil
+	}
+	return tea.Tick(rate, func(time.Time) tea.Msg {
+		return OutputMsg{}
+	})
+}
+
+// PrepareCmd creates a command with the given arguments and optional
+// working directory. The context parameter controls the command's lifetime.
+func PrepareCmd(ctx context.Context, name string, args []string, workDir string, env []string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, name, args...)
+	cmd.Dir = workDir
+	if len(env) > 0 {
+		cmd.Env = append(os.Environ(), env...)
+	} else {
+		cmd.Env = os.Environ()
+	}
+	return cmd
+}
+
+// isExpectedIOError returns true for errors that are expected when the
+// terminal is closing (EOF, closed pipe, etc).
+func isExpectedIOError(err error) bool {
+	if err == nil {
+		return true
+	}
+	if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
+		return true
+	}
+	// Check for common close-related error messages.
+	msg := err.Error()
+	return errors.Is(err, context.Canceled) ||
+		msg == "file already closed" ||
+		msg == "read/write on closed pipe"
+}

internal/terminal/terminal_unix.go 🔗

@@ -0,0 +1,15 @@
+//go:build unix
+
+package terminal
+
+import "syscall"
+
+// sysProcAttr returns the syscall.SysProcAttr for Unix systems.
+// It creates a new session and sets the controlling terminal to isolate
+// the subprocess from the parent terminal.
+func sysProcAttr() *syscall.SysProcAttr {
+	return &syscall.SysProcAttr{
+		Setsid:  true,
+		Setctty: true,
+	}
+}

internal/terminal/terminal_windows.go 🔗

@@ -0,0 +1,12 @@
+//go:build windows
+
+package terminal
+
+import "syscall"
+
+// sysProcAttr returns the syscall.SysProcAttr for Windows systems.
+// On Windows, ConPTY handles process isolation differently, so we don't
+// need special attributes.
+func sysProcAttr() *syscall.SysProcAttr {
+	return &syscall.SysProcAttr{}
+}

internal/tui/components/chat/editor/editor.go 🔗

@@ -264,7 +264,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		// Open command palette when "/" is pressed on empty prompt
 		case msg.String() == "/" && m.IsEmpty():
 			return m, util.CmdHandler(dialogs.OpenDialogMsg{
-				Model: commands.NewCommandDialog(m.session.ID),
+				Model: commands.NewCommandDialog(m.app.Context(), m.session.ID),
 			})
 		// Completions
 		case msg.String() == "@" && !m.isCompletionsOpen &&

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -1,6 +1,7 @@
 package commands
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"slices"
@@ -17,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -69,6 +71,7 @@ type commandDialogCmp struct {
 	userCommands []Command             // User-defined commands
 	mcpPrompts   *csync.Slice[Command] // MCP prompts
 	sessionID    string                // Current session ID
+	ctx          context.Context
 }
 
 type (
@@ -83,12 +86,13 @@ type (
 	OpenReasoningDialogMsg struct{}
 	OpenExternalEditorMsg  struct{}
 	ToggleYoloModeMsg      struct{}
+	OpenLazygitMsg         struct{}
 	CompactMsg             struct {
 		SessionID string
 	}
 )
 
-func NewCommandDialog(sessionID string) CommandsDialog {
+func NewCommandDialog(ctx context.Context, sessionID string) CommandsDialog {
 	keyMap := DefaultCommandsDialogKeyMap()
 	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
@@ -117,6 +121,7 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 		selected:    SystemCommands,
 		sessionID:   sessionID,
 		mcpPrompts:  csync.NewSlice[Command](),
+		ctx:         ctx,
 	}
 }
 
@@ -434,6 +439,19 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 
+	// Add lazygit command if lazygit is installed.
+	sh := shell.NewShell(nil)
+	if _, _, err := sh.Exec(c.ctx, "which lazygit"); err == nil {
+		commands = append(commands, Command{
+			ID:          "lazygit",
+			Title:       "Open Lazygit",
+			Description: "Open lazygit for git operations",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(OpenLazygitMsg{})
+			},
+		})
+	}
+
 	return append(commands, []Command{
 		{
 			ID:          "toggle_yolo",

internal/tui/components/dialogs/lazygit/lazygit.go 🔗

@@ -0,0 +1,113 @@
+// Package lazygit provides a dialog component for embedding lazygit in the TUI.
+package lazygit
+
+import (
+	"context"
+	"fmt"
+	"image/color"
+	"os"
+
+	"github.com/charmbracelet/crush/internal/terminal"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/termdialog"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+// DialogID is the unique identifier for the lazygit dialog.
+const DialogID dialogs.DialogID = "lazygit"
+
+// NewDialog creates a new lazygit dialog. The context controls the lifetime
+// of the lazygit process - when cancelled, the process will be killed.
+func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
+	configFile := createThemedConfig()
+
+	cmd := terminal.PrepareCmd(
+		ctx,
+		"lazygit",
+		nil,
+		workingDir,
+		[]string{"LG_CONFIG_FILE=" + configFile},
+	)
+
+	return termdialog.New(termdialog.Config{
+		ID:         DialogID,
+		Title:      "Lazygit",
+		LoadingMsg: "Starting lazygit...",
+		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
+		OnClose: func() {
+			if configFile != "" {
+				_ = os.Remove(configFile)
+			}
+		},
+	})
+}
+
+// colorToHex converts a color.Color to a hex string.
+func colorToHex(c color.Color) string {
+	r, g, b, _ := c.RGBA()
+	return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
+}
+
+// createThemedConfig creates a temporary lazygit config file with Crush theme.
+func createThemedConfig() string {
+	t := styles.CurrentTheme()
+
+	config := fmt.Sprintf(`gui:
+  border: rounded
+  showFileTree: true
+  showRandomTip: false
+  showCommandLog: false
+  showBottomLine: true
+  showPanelJumps: false
+  nerdFontsVersion: ""
+  showFileIcons: false
+  theme:
+    activeBorderColor:
+      - "%s"
+      - bold
+    inactiveBorderColor:
+      - "%s"
+    searchingActiveBorderColor:
+      - "%s"
+      - bold
+    optionsTextColor:
+      - "%s"
+    selectedLineBgColor:
+      - "%s"
+    inactiveViewSelectedLineBgColor:
+      - "%s"
+    cherryPickedCommitFgColor:
+      - "%s"
+    cherryPickedCommitBgColor:
+      - "%s"
+    markedBaseCommitFgColor:
+      - "%s"
+    markedBaseCommitBgColor:
+      - "%s"
+    unstagedChangesColor:
+      - "%s"
+    defaultFgColor:
+      - default
+`,
+		colorToHex(t.BorderFocus),
+		colorToHex(t.Border),
+		colorToHex(t.Warning),
+		colorToHex(t.FgHalfMuted),
+		colorToHex(t.Primary),
+		colorToHex(t.BgSubtle),
+		colorToHex(t.Secondary),
+		colorToHex(t.BgOverlay),
+		colorToHex(t.Warning),
+		colorToHex(t.BgOverlay),
+		colorToHex(t.Error),
+	)
+
+	f, err := os.CreateTemp("", "crush-lazygit-*.yml")
+	if err != nil {
+		return ""
+	}
+	defer f.Close()
+
+	_, _ = f.WriteString(config)
+	return f.Name()
+}

internal/tui/components/dialogs/termdialog/termdialog.go 🔗

@@ -0,0 +1,232 @@
+// Package termdialog provides a reusable dialog component for embedding
+// terminal applications in the TUI.
+package termdialog
+
+import (
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/terminal"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const (
+	// headerHeight is the height of the dialog header (title + padding).
+	headerHeight = 2
+	// fullscreenWidthBreakpoint is the width below which the dialog goes
+	// fullscreen. Matches CompactModeWidthBreakpoint in chat.go.
+	fullscreenWidthBreakpoint = 120
+)
+
+// Config holds configuration for a terminal dialog.
+type Config struct {
+	// ID is the unique identifier for this dialog.
+	ID dialogs.DialogID
+	// Title is displayed in the dialog header.
+	Title string
+	// LoadingMsg is shown while the terminal is starting.
+	LoadingMsg string
+	// Term is the terminal to embed.
+	Term *terminal.Terminal
+	// OnClose is called when the dialog is closed (optional).
+	OnClose func()
+}
+
+// Dialog is a dialog that embeds a terminal application.
+type Dialog struct {
+	id         dialogs.DialogID
+	title      string
+	loadingMsg string
+	term       *terminal.Terminal
+	onClose    func()
+
+	wWidth     int
+	wHeight    int
+	width      int
+	height     int
+	fullscreen bool
+}
+
+// New creates a new terminal dialog with the given configuration.
+func New(cfg Config) *Dialog {
+	loadingMsg := cfg.LoadingMsg
+	if loadingMsg == "" {
+		loadingMsg = "Starting..."
+	}
+
+	return &Dialog{
+		id:         cfg.ID,
+		title:      cfg.Title,
+		loadingMsg: loadingMsg,
+		term:       cfg.Term,
+		onClose:    cfg.OnClose,
+	}
+}
+
+func (d *Dialog) Init() tea.Cmd {
+	return nil
+}
+
+func (d *Dialog) Update(msg tea.Msg) (util.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		return d.handleResize(msg)
+
+	case terminal.ExitMsg:
+		return d, util.CmdHandler(dialogs.CloseDialogMsg{})
+
+	case terminal.OutputMsg:
+		if d.term.Closed() {
+			return d, nil
+		}
+		return d, d.term.RefreshCmd()
+
+	case tea.KeyPressMsg:
+		return d.handleKey(msg)
+
+	case tea.PasteMsg:
+		d.term.SendPaste(msg.Content)
+		return d, nil
+
+	case tea.MouseMsg:
+		return d.handleMouse(msg)
+	}
+
+	return d, nil
+}
+
+func (d *Dialog) handleResize(msg tea.WindowSizeMsg) (util.Model, tea.Cmd) {
+	d.wWidth = msg.Width
+	d.wHeight = msg.Height
+
+	// Go fullscreen when window is below compact mode breakpoint.
+	d.fullscreen = msg.Width < fullscreenWidthBreakpoint
+
+	var outerWidth, outerHeight int
+	if d.fullscreen {
+		outerWidth = msg.Width
+		outerHeight = msg.Height
+	} else {
+		// Dialog takes up 85% of the screen to show it's embedded.
+		outerWidth = int(float64(msg.Width) * 0.85)
+		outerHeight = int(float64(msg.Height) * 0.85)
+
+		// Cap at reasonable maximums.
+		if outerWidth > msg.Width-6 {
+			outerWidth = msg.Width - 6
+		}
+		if outerHeight > msg.Height-4 {
+			outerHeight = msg.Height - 4
+		}
+	}
+
+	// Inner dimensions = outer - border (1 char each side = 2 total).
+	d.width = max(outerWidth-2, 40)
+	d.height = max(outerHeight-2, 10)
+
+	// Terminal height excludes the header.
+	termHeight := max(d.height-headerHeight, 5)
+
+	// Start the terminal if not started.
+	if !d.term.Started() && d.width > 0 && termHeight > 0 {
+		if err := d.term.Resize(d.width, termHeight); err != nil {
+			return d, util.ReportError(err)
+		}
+		if err := d.term.Start(); err != nil {
+			return d, util.ReportError(err)
+		}
+		return d, tea.Batch(d.term.WaitCmd(), d.term.RefreshCmd())
+	}
+
+	// Resize existing terminal.
+	if err := d.term.Resize(d.width, termHeight); err != nil {
+		return d, util.ReportError(err)
+	}
+	return d, nil
+}
+
+func (d *Dialog) handleKey(msg tea.KeyPressMsg) (util.Model, tea.Cmd) {
+	// Forward all keys to the terminal.
+	if msg.Text != "" {
+		d.term.SendText(msg.Text)
+	} else {
+		d.term.SendKey(msg)
+	}
+	return d, nil
+}
+
+func (d *Dialog) handleMouse(msg tea.MouseMsg) (util.Model, tea.Cmd) {
+	row, col := d.Position()
+
+	// Adjust coordinates for dialog position.
+	adjust := func(x, y int) (int, int) {
+		return x - col - 1, y - row - 1 - headerHeight
+	}
+
+	switch ev := msg.(type) {
+	case tea.MouseClickMsg:
+		ev.X, ev.Y = adjust(ev.X, ev.Y)
+		d.term.SendMouse(ev)
+	case tea.MouseReleaseMsg:
+		ev.X, ev.Y = adjust(ev.X, ev.Y)
+		d.term.SendMouse(ev)
+	case tea.MouseWheelMsg:
+		ev.X, ev.Y = adjust(ev.X, ev.Y)
+		d.term.SendMouse(ev)
+	case tea.MouseMotionMsg:
+		ev.X, ev.Y = adjust(ev.X, ev.Y)
+		d.term.SendMouse(ev)
+	}
+	return d, nil
+}
+
+func (d *Dialog) View() string {
+	t := styles.CurrentTheme()
+
+	var termContent string
+	if d.term.Started() {
+		termContent = d.term.Render()
+	} else {
+		termContent = d.loadingMsg
+	}
+
+	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(d.title, d.width-2))
+	content := lipgloss.JoinVertical(lipgloss.Left, header, termContent)
+
+	dialogStyle := t.S().Base.
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+
+	return dialogStyle.Render(content)
+}
+
+func (d *Dialog) Position() (int, int) {
+	if d.fullscreen {
+		return 0, 0
+	}
+
+	dialogWidth := d.width + 2
+	dialogHeight := d.height + 2
+
+	row := max((d.wHeight-dialogHeight)/2, 0)
+	col := max((d.wWidth-dialogWidth)/2, 0)
+
+	return row, col
+}
+
+func (d *Dialog) ID() dialogs.DialogID {
+	return d.id
+}
+
+func (d *Dialog) Close() tea.Cmd {
+	_ = d.term.Close()
+
+	if d.onClose != nil {
+		d.onClose()
+	}
+
+	return nil
+}

internal/tui/tui.go 🔗

@@ -28,6 +28,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/lazygit"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -303,6 +304,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
 		})
+	// Lazygit
+	case commands.OpenLazygitMsg:
+		if a.dialog.ActiveDialogID() == lazygit.DialogID {
+			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: lazygit.NewDialog(a.app.Context(), a.app.Config().WorkingDir()),
+		})
 	// Permissions
 	case pubsub.Event[permission.PermissionNotification]:
 		item, ok := a.pages[a.currentPage]
@@ -508,7 +517,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			return nil
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: commands.NewCommandDialog(a.selectedSessionID),
+			Model: commands.NewCommandDialog(a.app.Context(), a.selectedSessionID),
 		})
 	case key.Matches(msg, a.keyMap.Models):
 		// if the app is not configured show no models