Detailed changes
@@ -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
@@ -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=
@@ -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 {
@@ -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"
+}
@@ -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,
+ }
+}
@@ -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{}
+}
@@ -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 &&
@@ -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",
@@ -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()
+}
@@ -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
+}
@@ -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