Detailed changes
@@ -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 {
@@ -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
}
)
@@ -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
},
})
}
@@ -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
},
})
}
@@ -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
@@ -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)),
+ })
+ },
+ })
+}
@@ -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)
@@ -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]