diff --git a/CLAUDE.md b/CLAUDE.md index c7c052b7..235812e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ cagent is a multi-agent AI system with hierarchical agent structure and pluggabl #### Command Layer (`cmd/root/`) - **Multiple interfaces**: CLI (`run.go`), TUI (default for `run` command), API (`api.go`) -- **Interactive commands**: `/exit`, `/reset`, `/eval`, `/usage`, `/compact` during sessions +- **Interactive commands**: `/exit`, `/reset`, `/eval`, `/usage`, `/compact`, `/copy` during sessions - **Debug support**: `--debug` flag for detailed logging - **Gateway mode**: SSE-based transport for external MCP clients like Claude Code @@ -290,6 +290,7 @@ agents: - `/reset` - Clear session history - `/usage` - Show token usage statistics - `/compact` - Generate summary and compact session history +- `/copy` - Copy the current conversation to clipboard - `/eval` - Save evaluation data ## File Locations and Patterns diff --git a/cmd/root/run.go b/cmd/root/run.go index 1869ff97..51d8742c 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea/v2" "github.com/fatih/color" "github.com/spf13/cobra" @@ -641,12 +642,86 @@ func runUserCommand(userInput string, sess *session.Session, rt runtime.Runtime, fmt.Printf("%s\n", yellow("No summary generated")) } + return true, nil + case "/copy": + // Copy the conversation to clipboard + transcript := generatePlainTextTranscript(sess) + if transcript == "" { + fmt.Printf("%s\n", yellow("Conversation is empty; nothing copied.")) + return true, nil + } + + if err := clipboard.WriteAll(transcript); err != nil { + fmt.Printf("%s\n", yellow("Failed to copy conversation: %s", err.Error())) + return true, err + } + + fmt.Printf("%s\n", yellow("Conversation copied to clipboard.")) return true, nil } return false, nil } +// generatePlainTextTranscript generates a plain text transcript from the session +func generatePlainTextTranscript(sess *session.Session) string { + var builder strings.Builder + + for _, item := range sess.Messages { + if item.IsMessage() { + msg := item.Message + // Skip implicit messages + if msg.Implicit { + continue + } + + switch msg.Message.Role { + case chat.MessageRoleUser: + writeTranscriptSection(&builder, "User", msg.Message.Content) + case chat.MessageRoleAssistant: + agentLabel := msg.AgentName + if agentLabel == "" || agentLabel == "root" { + agentLabel = "Assistant" + } + writeTranscriptSection(&builder, agentLabel, msg.Message.Content) + case chat.MessageRoleTool: + // Format tool results + msgContent := msg.Message.Content + if msg.Message.Name != "" { + writeTranscriptSection(&builder, fmt.Sprintf("Tool Result (%s)", msg.Message.Name), msgContent) + } else { + writeTranscriptSection(&builder, "Tool Result", msgContent) + } + } + } else if item.IsSubSession() { + // Recursively process sub-sessions + subTranscript := generatePlainTextTranscript(item.SubSession) + if subTranscript != "" { + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(subTranscript) + } + } + } + + return strings.TrimSpace(builder.String()) +} + +// writeTranscriptSection writes a section to the transcript builder +func writeTranscriptSection(builder *strings.Builder, title, text string) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return + } + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(title) + builder.WriteString(":\n") + builder.WriteString(trimmed) +} + // parseAttachCommand parses user input for /attach commands // Returns the message text (with /attach commands removed) and the attachment path func parseAttachCommand(input string) (messageText, attachPath string) { diff --git a/docs/USAGE.md b/docs/USAGE.md index f57b8b0e..1e2d7f0d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -77,6 +77,7 @@ During CLI sessions, you can use special commands: | `/reset` | Clear conversation history | | `/eval` | Save current conversation for evaluation | | `/compact` | Compact conversation to lower context usage | +| `/copy` | Copy the current conversation to clipboard | ## 🔧 Configuration Reference diff --git a/pkg/app/app.go b/pkg/app/app.go index a1752c0d..c70333b2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -112,6 +112,13 @@ func (a *App) CompactSession() { } } +// ResetSession clears the conversation history +func (a *App) ResetSession() { + if a.session != nil { + a.session.Messages = []session.Item{} + } +} + // ResumeStartOAuth resumes the runtime with OAuth authorization confirmation func (a *App) ResumeStartOAuth(bool) { if a.runtime != nil { diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index e2ea2f8b..c58a29b7 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -182,6 +182,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, cmd case editor.SendMsg: + // Check for slash commands first + if handled, cmd := p.handleSlashCommand(msg.Content); handled { + return p, cmd + } cmd := p.processMessage(msg.Content) return p, cmd @@ -430,6 +434,31 @@ func (p *chatPage) switchFocus() { } } +// handleSlashCommand checks if the content is a slash command and handles it +// Returns (true, cmd) if command was handled, (false, nil) otherwise +func (p *chatPage) handleSlashCommand(content string) (bool, tea.Cmd) { + trimmed := content + // Check if it's a slash command + if trimmed == "" || trimmed[0] != '/' { + return false, nil + } + + switch trimmed { + case "/copy": + return true, p.CopySessionToClipboard() + case "/compact": + return true, p.CompactSession() + case "/reset": + // Reset the session + p.app.ResetSession() + cmd := p.messages.AddSystemMessage("Conversation history cleared.") + return true, tea.Batch(cmd, p.messages.ScrollToBottom()) + default: + // Not a recognized slash command + return false, nil + } +} + // processMessage processes a message with the runtime func (p *chatPage) processMessage(content string) tea.Cmd { if p.msgCancel != nil {