Просмотр исходного кода

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

Co-authored-by: opencode <[email protected]>
Joe Schmitt 7 месяцев назад
Родитель
Сommit
8bd250fb15

+ 1 - 0
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"),

+ 7 - 0
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"
@@ -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",

+ 90 - 0
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()
+}