feat(tui): add /export command to export conversation to editor (#989)

Co-authored-by: opencode <noreply@opencode.ai>
This commit is contained in:
Joe Schmitt 2025-07-15 14:53:21 -04:00 committed by GitHub
parent b1ab641905
commit 8bd250fb15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 0 deletions

View file

@ -83,6 +83,7 @@ export namespace Config {
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
switch_mode: z.string().optional().default("tab").describe("Switch mode"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_share: z.string().optional().default("<leader>s").describe("Share current session"),

View file

@ -94,6 +94,7 @@ const (
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
@ -164,6 +165,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>e"),
Trigger: []string{"editor"},
},
{
Name: SessionExportCommand,
Description: "export conversation",
Keybindings: parseBindings("<leader>x"),
Trigger: []string{"export"},
},
{
Name: SessionNewCommand,
Description: "new session",

View file

@ -2,6 +2,7 @@ package tui
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
@ -900,6 +901,56 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
}
// TODO: block until compaction is complete
a.app.CompactSession(context.Background())
case commands.SessionExportCommand:
if a.app.Session.ID == "" {
return a, toast.NewErrorToast("No active session to export.")
}
// Use current conversation history
messages := a.app.Messages
if len(messages) == 0 {
return a, toast.NewInfoToast("No messages to export.")
}
// Format to Markdown
markdownContent := formatConversationToMarkdown(messages)
// Check if EDITOR is set
editor := os.Getenv("EDITOR")
if editor == "" {
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
}
// Create and write to temp file
tmpfile, err := os.CreateTemp("", "conversation-*.md")
if err != nil {
slog.Error("Failed to create temp file", "error", err)
return a, toast.NewErrorToast("Failed to create temporary file.")
}
_, err = tmpfile.WriteString(markdownContent)
if err != nil {
slog.Error("Failed to write to temp file", "error", err)
tmpfile.Close()
os.Remove(tmpfile.Name())
return a, toast.NewErrorToast("Failed to write conversation to file.")
}
tmpfile.Close()
// Open in editor
c := exec.Command(editor, tmpfile.Name())
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
slog.Error("Failed to open editor for conversation", "error", err)
}
// Clean up the file after editor closes
os.Remove(tmpfile.Name())
return nil
})
cmds = append(cmds, cmd)
case commands.ToolDetailsCommand:
message := "Tool details are now visible"
if a.messages.ToolDetailsVisible() {
@ -1055,3 +1106,42 @@ func NewModel(app *app.App) tea.Model {
return model
}
func formatConversationToMarkdown(messages []app.Message) string {
var builder strings.Builder
builder.WriteString("# Conversation History\n\n")
for _, msg := range messages {
builder.WriteString("---\n\n")
var role string
var timestamp time.Time
switch info := msg.Info.(type) {
case opencode.UserMessage:
role = "User"
timestamp = time.UnixMilli(int64(info.Time.Created))
case opencode.AssistantMessage:
role = "Assistant"
timestamp = time.UnixMilli(int64(info.Time.Created))
default:
continue
}
builder.WriteString(fmt.Sprintf("**%s** (*%s*)\n\n", role, timestamp.Format("2006-01-02 15:04:05")))
for _, part := range msg.Parts {
switch p := part.(type) {
case opencode.TextPart:
builder.WriteString("> " + strings.ReplaceAll(p.Text, "\n", "\n> ") + "\n\n")
case opencode.FilePart:
builder.WriteString(fmt.Sprintf("> [File: %s]\n\n", p.Filename))
case opencode.ToolPart:
builder.WriteString(fmt.Sprintf("> [Tool: %s]\n\n", p.Tool))
}
}
}
return builder.String()
}