From 1f5680baaaaa08e893c53400fd675c2f4ebf0dc1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 11 Dec 2025 20:06:27 +0100 Subject: [PATCH] feat: vt integration & lazygit --- 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 +- .../components/dialogs/commands/commands.go | 20 +- .../tui/components/dialogs/lazygit/lazygit.go | 113 ++++++ .../dialogs/termdialog/termdialog.go | 232 +++++++++++ internal/tui/tui.go | 11 +- 11 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 internal/terminal/terminal.go create mode 100644 internal/terminal/terminal_unix.go create mode 100644 internal/terminal/terminal_windows.go create mode 100644 internal/tui/components/dialogs/lazygit/lazygit.go create mode 100644 internal/tui/components/dialogs/termdialog/termdialog.go diff --git a/go.mod b/go.mod index c564d9c39535f892c1a96eacacd052efe78f0bf3..30ad9249946701eec07d5723abc9ef97ee34a9c5 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 352c35f6cc90d864120c87ec9141cc4c007c20a9..3cbc8f2632181ba2cf4de41747248a5148ccc5c6 100644 --- a/go.sum +++ b/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= diff --git a/internal/app/app.go b/internal/app/app.go index 1694a0ecb39266cdd4676a346cfdeaa2be47579a..f4ff0ccb37b8ae422c89a1b8e6e037dbddbf157b 100644 --- a/internal/app/app.go +++ b/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 { diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000000000000000000000000000000000000..86758e72f093978b16b739b6ac9b1dcfcc4d5135 --- /dev/null +++ b/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" +} diff --git a/internal/terminal/terminal_unix.go b/internal/terminal/terminal_unix.go new file mode 100644 index 0000000000000000000000000000000000000000..1ed837d151bd06de0c9563f6f7a7705163250b26 --- /dev/null +++ b/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, + } +} diff --git a/internal/terminal/terminal_windows.go b/internal/terminal/terminal_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..72d54b0853e9cdc70d3904e92ebc9a87a40dcb40 --- /dev/null +++ b/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{} +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index c3f4ff06d631c3df757d6a7a1d12428e66a3ae58..939039525e7dc5eaf84a009e9fae0460fb48136f 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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 && diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 1e6bfd9fc0791ba45b8c76edc3ca745e0fa53528..0b7bd6cd342600bf6611079a7d3cbc091a3cfb19 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/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", diff --git a/internal/tui/components/dialogs/lazygit/lazygit.go b/internal/tui/components/dialogs/lazygit/lazygit.go new file mode 100644 index 0000000000000000000000000000000000000000..ef1c425b594f81a17f9752cdc4a83da64d3de30d --- /dev/null +++ b/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() +} diff --git a/internal/tui/components/dialogs/termdialog/termdialog.go b/internal/tui/components/dialogs/termdialog/termdialog.go new file mode 100644 index 0000000000000000000000000000000000000000..1949cc34d430561b2a6ac9843845f861661d37e5 --- /dev/null +++ b/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 +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e91fae5592b8d51963e524d0662d868cbfed6869..7d2e447b4510cf5e9de4bd0c7eea47f710b3d3e2 100644 --- a/internal/tui/tui.go +++ b/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