From 019b34b6d937c6e2583dbe60e3d6550a1f9aebbc Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 11 Dec 2025 22:42:04 +0100 Subject: [PATCH] feat: add support for using external editor --- internal/tui/components/chat/editor/editor.go | 11 +- .../components/dialogs/commands/commands.go | 12 +- .../tui/components/dialogs/ghdash/ghdash.go | 5 +- .../tui/components/dialogs/lazygit/lazygit.go | 5 +- .../dialogs/termdialog/termdialog.go | 8 +- .../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(-) create mode 100644 internal/tui/components/dialogs/tui_editor/editor.go diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 939039525e7dc5eaf84a009e9fae0460fb48136f..a524ef8f1484b8287095ebac14604f9683b9cdad 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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 { diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index ec2088a8fbbb1e9c652b0b5ffaf012dc997cc764..ec22a18a233cbe2667254d13dc3a96a4bc5a71c1 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/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 } ) diff --git a/internal/tui/components/dialogs/ghdash/ghdash.go b/internal/tui/components/dialogs/ghdash/ghdash.go index 682a7e7991286c7b740645bc183c868cdfa87eea..61ffc87cd509e8829a9f5fbc6cb59c47a2a8bbb7 100644 --- a/internal/tui/components/dialogs/ghdash/ghdash.go +++ b/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 }, }) } diff --git a/internal/tui/components/dialogs/lazygit/lazygit.go b/internal/tui/components/dialogs/lazygit/lazygit.go index ce88aed8029eedf0810810ed12f2098e73f18560..6683c1bf618367ed0dc7f9881b07eebccdbed3ce 100644 --- a/internal/tui/components/dialogs/lazygit/lazygit.go +++ b/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 }, }) } diff --git a/internal/tui/components/dialogs/termdialog/termdialog.go b/internal/tui/components/dialogs/termdialog/termdialog.go index 83e5eb1232af7bb47f848c299f05e6d3710e24f4..69695146e4ca2262dc21d3a78b0a10bcfb7e28b4 100644 --- a/internal/tui/components/dialogs/termdialog/termdialog.go +++ b/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 diff --git a/internal/tui/components/dialogs/tui_editor/editor.go b/internal/tui/components/dialogs/tui_editor/editor.go new file mode 100644 index 0000000000000000000000000000000000000000..5d5b92300b458ea1c5c624c61a3c775b46964d39 --- /dev/null +++ b/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)), + }) + }, + }) +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 0166dc6d9d9d5ede72f41235850db309f3b9f31d..d2e00b93a8ba196800077906f5d12f429aeaf874 100644 --- a/internal/tui/page/chat/chat.go +++ b/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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a519fec0978bf5c72d35dce5207fad518ea40e7f..fb809c39760fae789799fc5a45b795125efd1e06 100644 --- a/internal/tui/tui.go +++ b/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]