diff --git a/go.mod b/go.mod index 513059544b5c3e59482bcf6054419b05745431a0..b891fc8cbee18798a61c0f3d0b7ff0c0e9d02f96 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/gen2brain/beeep v0.11.1 @@ -93,6 +95,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 @@ -100,6 +104,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 76cd36ab009b8521c0d01eb6e5df0a0578978b9d..744f1812b10d73d8d776892f3cf050ace2f09eae 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,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= @@ -122,8 +126,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 f25f6274a8b02ffff6b5ddb192c11818fd2a074f..9324a908c0a91d93eae03a7d1dc5e8b0cc08053d 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..de7b97aa54d9404ad905f9e67b690eefaa7400d8 --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,395 @@ +// 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 + cursorVisible bool + 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, + cursorVisible: true, // Cursor is visible by default + } +} + +// 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 and cursor visibility. +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 + } + }, + CursorVisibility: func(visible bool) { + t.cursorVisible = visible + }, + }) +} + +// 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() +} + +// CursorPosition returns the current cursor position in the terminal. +// Returns (-1, -1) if the terminal is not started, closed, or cursor is hidden. +func (t *Terminal) CursorPosition() (x, y int) { + t.mu.RLock() + defer t.mu.RUnlock() + + if t.vterm == nil || !t.started || t.closed || !t.cursorVisible { + return -1, -1 + } + + pos := t.vterm.CursorPosition() + return pos.X, pos.Y +} + +// 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 c0153cb1b592830ffd52fa8bc152bc7fff01d298..8590098a59c6f455823c0af3bae5a801a1986c1f 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 cf951f40a62c03ac3c7ab781f574bb5076ae6c80..634fc84ebaf6a571f3bbf8da6b9956520a9e716f 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 ( "git.secluded.site/crush/internal/config" "git.secluded.site/crush/internal/csync" "git.secluded.site/crush/internal/pubsub" + "git.secluded.site/crush/internal/shell" "git.secluded.site/crush/internal/tui/components/chat" "git.secluded.site/crush/internal/tui/components/core" "git.secluded.site/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..ec41de721f51f081340b30ba0b0f7a0a8b079a27 --- /dev/null +++ b/internal/tui/components/dialogs/lazygit/lazygit.go @@ -0,0 +1,117 @@ +// Package lazygit provides a dialog component for embedding lazygit in the TUI. +package lazygit + +import ( + "context" + "fmt" + "image/color" + "os" + + "git.secluded.site/crush/internal/terminal" + "git.secluded.site/crush/internal/tui/components/dialogs" + "git.secluded.site/crush/internal/tui/components/dialogs/termdialog" + "git.secluded.site/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. +// Theme mappings align with Crush's UX patterns: +// - Borders: BorderFocus (purple) for active, Border (gray) for inactive +// - Selection: Primary (purple) background matches app's TextSelected style +// - Status: Success (green), Error (red), Info (blue), Warning (orange) +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.FgMuted), + colorToHex(t.Info), + colorToHex(t.FgMuted), + colorToHex(t.Primary), + colorToHex(t.BgSubtle), + colorToHex(t.Success), + colorToHex(t.BgSubtle), + colorToHex(t.Info), + colorToHex(t.BgSubtle), + 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..237be7af462eb3ee71a6097acf39e70720426f2b --- /dev/null +++ b/internal/tui/components/dialogs/termdialog/termdialog.go @@ -0,0 +1,250 @@ +// 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" + + "git.secluded.site/crush/internal/terminal" + "git.secluded.site/crush/internal/tui/components/core" + "git.secluded.site/crush/internal/tui/components/dialogs" + "git.secluded.site/crush/internal/tui/styles" + "git.secluded.site/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) { + 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 +} + +// Cursor returns the cursor position adjusted for the dialog's screen position. +// Returns nil if the terminal cursor is hidden or not available. +func (d *Dialog) Cursor() *tea.Cursor { + x, y := d.term.CursorPosition() + if x < 0 || y < 0 { + return nil + } + + t := styles.CurrentTheme() + row, col := d.Position() + cursor := tea.NewCursor(x, y) + cursor.X += col + 1 + cursor.Y += row + 1 + headerHeight + cursor.Color = t.Secondary + cursor.Shape = tea.CursorBlock + cursor.Blink = true + return cursor +} + +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 a5e9f165f904ad692adac1b568da4a6a55ca993c..5cd49d1296ff307ce5b497f6dbe83275e48ce7bd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -16,7 +16,6 @@ import ( "git.secluded.site/crush/internal/app" "git.secluded.site/crush/internal/config" "git.secluded.site/crush/internal/event" - "git.secluded.site/crush/internal/notification" "git.secluded.site/crush/internal/permission" "git.secluded.site/crush/internal/pubsub" "git.secluded.site/crush/internal/stringext" @@ -29,6 +28,7 @@ import ( "git.secluded.site/crush/internal/tui/components/dialogs" "git.secluded.site/crush/internal/tui/components/dialogs/commands" "git.secluded.site/crush/internal/tui/components/dialogs/filepicker" + "git.secluded.site/crush/internal/tui/components/dialogs/lazygit" "git.secluded.site/crush/internal/tui/components/dialogs/models" "git.secluded.site/crush/internal/tui/components/dialogs/permissions" "git.secluded.site/crush/internal/tui/components/dialogs/quit" @@ -37,7 +37,6 @@ import ( "git.secluded.site/crush/internal/tui/page/chat" "git.secluded.site/crush/internal/tui/styles" "git.secluded.site/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" "golang.org/x/mod/semver" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -109,9 +108,6 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, tea.RequestTerminalVersion) } - // Request focus event support from the terminal. - cmds = append(cmds, tea.Raw(ansi.RequestModeFocusEvent)) - return tea.Batch(cmds...) } @@ -122,18 +118,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.isConfigured = config.HasInitialDataConfig() switch msg := msg.(type) { - case tea.ModeReportMsg: - if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() { - notification.SetFocusSupport(true) - notification.SetFocused(true) - } - return a, nil - case tea.FocusMsg: - notification.SetFocused(true) - return a, nil - case tea.BlurMsg: - notification.SetFocused(false) - return a, nil case tea.EnvMsg: // Is this Windows Terminal? if !a.sendProgressBar { @@ -320,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] @@ -333,9 +325,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, itemCmd case pubsub.Event[permission.PermissionRequest]: - // Send notification if window is not focused. - notifBody := fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName) - _ = notification.Send("Crush is waiting...", notifBody) return a, util.CmdHandler(dialogs.OpenDialogMsg{ Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{ DiffMode: config.Get().Options.TUI.DiffMode, @@ -528,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 @@ -612,7 +601,6 @@ func (a *appModel) View() tea.View { view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = t.BgBase - view.ReportFocus = true if a.wWidth < 25 || a.wHeight < 15 { view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight). Align(lipgloss.Center, lipgloss.Center).