package tui import ( "cmp" "errors" "fmt" "io" "os" "os/exec" "strings" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) func (m ChatModel) openEditorCmd() tea.Cmd { args := strings.Fields(cmp.Or(os.Getenv("EDITOR"), os.Getenv("VISUAL"), "nano")) if len(args) == 0 { args = []string{"nano"} } process := &promptEditorProcess{ editorProcess: editorProcess{exec.Command(args[0], args[1:]...), m.graphemes}, prompt: m.TextArea.Value(), } sessionID, generation := m.SessionID, m.Generation return tea.Exec(process, func(err error) tea.Msg { return ChatEditorFinishedMsg{SessionID: sessionID, Generation: generation, Text: process.text, Edited: process.edited, Err: err} }) } // promptEditorProcess owns the prompt file for the complete editor operation. // Its completion message carries text even when the UI no longer owns the session. type promptEditorProcess struct { editorProcess prompt string text string edited bool } func (p *promptEditorProcess) Run() error { file, err := os.CreateTemp("", "albedo-prompt-*.md") if err != nil { return fmt.Errorf("could not create temporary file: %w", err) } path := file.Name() defer os.Remove(path) if _, err := file.WriteString(p.prompt); err != nil { file.Close() return fmt.Errorf("could not write to temporary file: %w", err) } if err := file.Close(); err != nil { return fmt.Errorf("could not close temporary file: %w", err) } p.Args = append(p.Args, path) commandErr, restoreErr := p.runAndRestore() if commandErr != nil { return errors.Join(commandErr, restoreErr) } data, err := os.ReadFile(path) if err != nil { return errors.Join(fmt.Errorf("could not read the edited prompt: %w", err), restoreErr) } p.text = string(data) p.edited = true return restoreErr } // editorProcess runs the editor on the terminal, then hands the terminal back // the way Bubble Tea left it. Bubble Tea repaints from where it left the // cursor and trusts the column, but leaving the alternate screen puts the // cursor back wherever the editor found it, and a terminal reply echoed // before the editor took raw mode can have moved it along the row; the // repaint then starts mid-row, wraps the header and scrolls it off the top. // Bubble Tea also turns grapheme widths off for the editor and never turns // them back on, while it keeps measuring by grapheme. type editorProcess struct { *exec.Cmd graphemes bool } func (p editorProcess) SetStdin(r io.Reader) { p.Stdin = r } func (p editorProcess) SetStdout(w io.Writer) { p.Stdout = w } func (p editorProcess) SetStderr(w io.Writer) { p.Stderr = w } func (p editorProcess) Run() error { return errors.Join(p.runAndRestore()) } func (p editorProcess) runAndRestore() (commandErr, restoreErr error) { commandErr = p.Cmd.Run() restore := "\r" if p.graphemes { restore += ansi.SetModeUnicodeCore } written, err := io.WriteString(p.Stdout, restore) if err == nil && written != len(restore) { err = io.ErrShortWrite } if err != nil { restoreErr = fmt.Errorf("could not restore terminal output after the editor: %w", err) } return commandErr, restoreErr }