feat: add support for using external editor

Kujtim Hoxha created

Change summary

internal/tui/components/chat/editor/editor.go            |  11 
internal/tui/components/dialogs/commands/commands.go     |  12 
internal/tui/components/dialogs/ghdash/ghdash.go         |   5 
internal/tui/components/dialogs/lazygit/lazygit.go       |   5 
internal/tui/components/dialogs/termdialog/termdialog.go |   8 
internal/tui/components/dialogs/tui_editor/editor.go     | 108 ++++++++++
internal/tui/page/chat/chat.go                           |  12 +
internal/tui/tui.go                                      |  22 ++
8 files changed, 172 insertions(+), 11 deletions(-)

Detailed changes

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

@@ -27,6 +27,7 @@ import (
 	"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/quit"
+	tuieditor "github.com/charmbracelet/crush/internal/tui/components/dialogs/tui_editor"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -96,7 +97,7 @@ type OpenEditorMsg struct {
 func (m *editorCmp) openEditor(value string) tea.Cmd {
 	editor := os.Getenv("EDITOR")
 	if editor == "" {
-		// Use platform-appropriate default editor
+		// Use platform-appropriate default editor.
 		if runtime.GOOS == "windows" {
 			editor = "notepad"
 		} else {
@@ -112,6 +113,14 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
 	if _, err := tmpfile.WriteString(value); err != nil {
 		return util.ReportError(err)
 	}
+
+	if tuieditor.IsTUIEditor(editor) {
+		return util.CmdHandler(commands.OpenEmbeddedEditorMsg{
+			FilePath: tmpfile.Name(),
+			Editor:   editor,
+		})
+	}
+
 	cmdStr := editor + " " + tmpfile.Name()
 	return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
 		if err != nil {

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

@@ -85,10 +85,14 @@ type (
 	ToggleThinkingMsg      struct{}
 	OpenReasoningDialogMsg struct{}
 	OpenExternalEditorMsg  struct{}
-	ToggleYoloModeMsg      struct{}
-	OpenLazygitMsg         struct{}
-	OpenGhDashMsg          struct{}
-	CompactMsg             struct {
+	OpenEmbeddedEditorMsg  struct {
+		FilePath string
+		Editor   string
+	}
+	ToggleYoloModeMsg struct{}
+	OpenLazygitMsg    struct{}
+	OpenGhDashMsg     struct{}
+	CompactMsg        struct {
 		SessionID string
 	}
 )

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

@@ -7,6 +7,8 @@ import (
 	"image/color"
 	"os"
 
+	tea "charm.land/bubbletea/v2"
+
 	"github.com/charmbracelet/crush/internal/terminal"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/termdialog"
@@ -35,10 +37,11 @@ func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
 		LoadingMsg: "Starting gh-dash...",
 		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
 		QuitHint:   "q to close",
-		OnClose: func() {
+		OnClose: func() tea.Cmd {
 			if configFile != "" {
 				_ = os.Remove(configFile)
 			}
+			return nil
 		},
 	})
 }

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

@@ -9,6 +9,8 @@ import (
 	"os"
 	"path/filepath"
 
+	tea "charm.land/bubbletea/v2"
+
 	"github.com/charmbracelet/crush/internal/terminal"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/termdialog"
@@ -38,12 +40,13 @@ func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
 		LoadingMsg: "Starting lazygit...",
 		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
 		QuitHint:   "q to close",
-		OnClose: func() {
+		OnClose: func() tea.Cmd {
 			if themeConfig != "" {
 				if err := os.Remove(themeConfig); err != nil {
 					slog.Debug("failed to remove lazygit theme config", "error", err, "path", themeConfig)
 				}
 			}
+			return nil
 		},
 	})
 }

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

@@ -34,8 +34,8 @@ type Config struct {
 	LoadingMsg string
 	// Term is the terminal to embed.
 	Term *terminal.Terminal
-	// OnClose is called when the dialog is closed (optional).
-	OnClose func()
+	// Can return a tea.Cmd to emit messages after close.
+	OnClose func() tea.Cmd
 	// QuitHint is shown in the header (e.g., "q to close"). If empty, no hint is shown.
 	QuitHint string
 }
@@ -46,7 +46,7 @@ type Dialog struct {
 	title      string
 	loadingMsg string
 	term       *terminal.Terminal
-	onClose    func()
+	onClose    func() tea.Cmd
 	quitHint   string
 
 	wWidth     int
@@ -263,7 +263,7 @@ func (d *Dialog) Close() tea.Cmd {
 	_ = d.term.Close()
 
 	if d.onClose != nil {
-		d.onClose()
+		return d.onClose()
 	}
 
 	return nil

internal/tui/components/dialogs/tui_editor/editor.go 🔗

@@ -0,0 +1,108 @@
+// Package tuieditor provides a dialog component for embedding terminal-based
+// editors (vim, nvim, nano, etc.) in the TUI.
+package tuieditor
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+
+	"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/util"
+)
+
+// DialogID is the unique identifier for the embedded editor dialog.
+const DialogID dialogs.DialogID = "tui_editor"
+
+// EditorResultMsg is sent when the embedded editor closes with the file content.
+type EditorResultMsg struct {
+	Content string
+	Err     error
+}
+
+// knownTUIEditors is a list of terminal-based editors that can be embedded.
+var knownTUIEditors = []string{
+	"vim",
+	"nvim",
+	"vi",
+	"nano",
+	"helix",
+	"hx",
+	"micro",
+	"emacs",
+	"joe",
+	"ne",
+	"jed",
+	"kak",
+	"pico",
+	"mcedit",
+	"mg",
+	"zile",
+}
+
+// IsTUIEditor returns true if the given editor command is a known TUI editor.
+func IsTUIEditor(editor string) bool {
+	base := filepath.Base(editor)
+	if idx := strings.Index(base, " "); idx != -1 {
+		base = base[:idx]
+	}
+	return slices.Contains(knownTUIEditors, base)
+}
+
+// Config holds configuration for the embedded editor dialog.
+type Config struct {
+	// FilePath is the path to the file to edit.
+	FilePath string
+	// Editor is the editor command to use.
+	Editor string
+	// WorkingDir is the working directory for the editor.
+	WorkingDir string
+}
+
+// NewDialog creates a new embedded editor dialog. The context controls the
+// lifetime of the editor process - when cancelled, the process will be killed.
+// When the editor exits, an EditorResultMsg is emitted with the file content.
+func NewDialog(ctx context.Context, cfg Config) *termdialog.Dialog {
+	editorCmd := cfg.Editor
+	if editorCmd == "" {
+		editorCmd = "nvim"
+	}
+
+	parts := strings.Fields(editorCmd)
+	cmdName := parts[0]
+	args := append(parts[1:], cfg.FilePath)
+
+	cmd := terminal.PrepareCmd(
+		ctx,
+		cmdName,
+		args,
+		cfg.WorkingDir,
+		nil,
+	)
+
+	filePath := cfg.FilePath
+
+	return termdialog.New(termdialog.Config{
+		ID:         DialogID,
+		Title:      "Editor",
+		LoadingMsg: "Starting editor...",
+		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
+		OnClose: func() tea.Cmd {
+			content, err := os.ReadFile(filePath)
+			_ = os.Remove(filePath)
+
+			if err != nil {
+				return util.CmdHandler(EditorResultMsg{Err: err})
+			}
+			return util.CmdHandler(EditorResultMsg{
+				Content: strings.TrimSpace(string(content)),
+			})
+		},
+	})
+}

internal/tui/page/chat/chat.go 🔗

@@ -34,6 +34,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
+	tuieditor "github.com/charmbracelet/crush/internal/tui/components/dialogs/tui_editor"
 	"github.com/charmbracelet/crush/internal/tui/page"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -267,6 +268,17 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		u, cmd := p.editor.Update(msg)
 		p.editor = u.(editor.Editor)
 		return p, cmd
+	case tuieditor.EditorResultMsg:
+		if msg.Err != nil {
+			return p, util.ReportError(msg.Err)
+		}
+		if msg.Content == "" {
+			return p, util.ReportWarn("Message is empty")
+		}
+		// Forward the result to the editor as OpenEditorMsg.
+		u, cmd := p.editor.Update(editor.OpenEditorMsg{Text: msg.Content})
+		p.editor = u.(editor.Editor)
+		return p, cmd
 	case pubsub.Event[session.Session]:
 		u, cmd := p.header.Update(msg)
 		p.header = u.(header.Header)

internal/tui/tui.go 🔗

@@ -34,6 +34,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
+	tuieditor "github.com/charmbracelet/crush/internal/tui/components/dialogs/tui_editor"
 	"github.com/charmbracelet/crush/internal/tui/page"
 	"github.com/charmbracelet/crush/internal/tui/page/chat"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -321,6 +322,27 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: ghdash.NewDialog(a.app.Context(), a.app.Config().WorkingDir()),
 		})
+	// Embedded TUI Editor
+	case commands.OpenEmbeddedEditorMsg:
+		if a.dialog.ActiveDialogID() == tuieditor.DialogID {
+			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: tuieditor.NewDialog(a.app.Context(), tuieditor.Config{
+				FilePath:   msg.FilePath,
+				Editor:     msg.Editor,
+				WorkingDir: a.app.Config().WorkingDir(),
+			}),
+		})
+	// Editor result - forward to page
+	case tuieditor.EditorResultMsg:
+		item, ok := a.pages[a.currentPage]
+		if !ok {
+			return a, nil
+		}
+		updated, itemCmd := item.Update(msg)
+		a.pages[a.currentPage] = updated
+		return a, itemCmd
 	// Permissions
 	case pubsub.Event[permission.PermissionNotification]:
 		item, ok := a.pages[a.currentPage]