tui: open input/messages for editing in $EDITOR

This commit is contained in:
Matt Low 2024-03-16 21:56:45 +00:00
parent 7a974d9764
commit a8ffdc156a
2 changed files with 76 additions and 2 deletions

View File

@ -2,8 +2,7 @@ package tui
// The terminal UI for lmcli, launched from the `lmcli chat` command // The terminal UI for lmcli, launched from the `lmcli chat` command
// TODO: // TODO:
// - binding to open selected message/input in $EDITOR // - ability to continue an incomplete or missing assistant response
// - ability to continue or retry previous response
// - conversation list view // - conversation list view
// - change model // - change model
// - rename conversation // - rename conversation
@ -33,6 +32,13 @@ const (
focusMessages focusMessages
) )
type editorTarget int
const (
input editorTarget = iota
selectedMessage
)
type model struct { type model struct {
width int width int
height int height int
@ -44,6 +50,7 @@ type model struct {
conversation *models.Conversation conversation *models.Conversation
messages []models.Message messages []models.Message
waitingForReply bool waitingForReply bool
editorTarget editorTarget
stopSignal chan interface{} stopSignal chan interface{}
replyChan chan models.Message replyChan chan models.Message
replyChunkChan chan string replyChunkChan chan string
@ -123,6 +130,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case msgTempfileEditorClosed:
contents := string(msg)
switch m.editorTarget {
case input:
m.input.SetValue(contents)
case selectedMessage:
m.setMessageContents(m.selectedMessage, contents)
if m.persistence && m.messages[m.selectedMessage].ID > 0 {
// update persisted message
err := m.ctx.Store.UpdateMessage(&m.messages[m.selectedMessage])
if err != nil {
cmds = append(cmds, wrapError(fmt.Errorf("Could not save edited message: %v", err)))
}
}
m.updateContent()
}
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@ -437,6 +460,11 @@ func (m *model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd {
m.focus = focusInput m.focus = focusInput
m.updateContent() m.updateContent()
m.input.Focus() m.input.Focus()
case "e":
message := m.messages[m.selectedMessage]
cmd := openTempfileEditor("message.*.md", message.Content, "# Edit the message below\n")
m.editorTarget = selectedMessage
return cmd
case "ctrl+k": case "ctrl+k":
if m.selectedMessage > 0 && len(m.messages) == len(m.messageOffsets) { if m.selectedMessage > 0 && len(m.messages) == len(m.messageOffsets) {
m.selectedMessage-- m.selectedMessage--
@ -508,6 +536,10 @@ func (m *model) handleInputKey(msg tea.KeyMsg) tea.Cmd {
m.updateContent() m.updateContent()
m.content.GotoBottom() m.content.GotoBottom()
return m.promptLLM() return m.promptLLM()
case "ctrl+e":
cmd := openTempfileEditor("message.*.md", m.input.Value(), "# Edit your input below\n")
m.editorTarget = input
return cmd
case "ctrl+r": case "ctrl+r":
if len(m.messages) == 0 { if len(m.messages) == 0 {
return nil return nil

42
pkg/tui/util.go Normal file
View File

@ -0,0 +1,42 @@
package tui
import (
"os"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type msgTempfileEditorClosed string
// openTempfileEditor opens an $EDITOR on a new temporary file with the given
// content. Upon closing, the contents of the file are read back returned
// wrapped in a msgTempfileEditorClosed returned by the tea.Cmd
func openTempfileEditor(pattern string, content string, placeholder string) tea.Cmd {
msgFile, _ := os.CreateTemp("/tmp", pattern)
err := os.WriteFile(msgFile.Name(), []byte(placeholder+content), os.ModeAppend)
if err != nil {
return wrapError(err)
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
c := exec.Command(editor, msgFile.Name())
return tea.ExecProcess(c, func(err error) tea.Msg {
bytes, err := os.ReadFile(msgFile.Name())
if err != nil {
return msgError(err)
}
fileContents := string(bytes)
if strings.HasPrefix(fileContents, placeholder) {
fileContents = fileContents[len(placeholder):]
}
stripped := strings.Trim(fileContents, "\n \t")
return msgTempfileEditorClosed(stripped)
})
}