summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJoe Schmitt <[email protected]>2025-07-15 14:53:21 -0400
committerGitHub <[email protected]>2025-07-15 13:53:21 -0500
commit8bd250fb155dae9b569eda3e3eb59d0651f41257 (patch)
treec4be1b0d0b30a5bf390e6284961369b07a0c10aa
parentb1ab6419054e6ee2fd34edbdcf8c2d7c6cd3b5d3 (diff)
downloadopencode-8bd250fb155dae9b569eda3e3eb59d0651f41257.tar.gz
opencode-8bd250fb155dae9b569eda3e3eb59d0651f41257.zip
feat(tui): add /export command to export conversation to editor (#989)
Co-authored-by: opencode <[email protected]>
-rw-r--r--packages/opencode/src/config/config.ts1
-rw-r--r--packages/tui/internal/commands/command.go7
-rw-r--r--packages/tui/internal/tui/tui.go90
3 files changed, 98 insertions, 0 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index ebcf065a1..851d68064 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -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"),
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index dfa7abdd0..f04bbfa12 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -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"
@@ -165,6 +166,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"editor"},
},
{
+ Name: SessionExportCommand,
+ Description: "export conversation",
+ Keybindings: parseBindings("<leader>x"),
+ Trigger: []string{"export"},
+ },
+ {
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 0e65e63be..49b588772 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -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()
+}