Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These commands already exist in the tui.go

cagent/pkg/tui/tui.go

Lines 293 to 333 in 6055909

func (a *appModel) buildCommandCategories() []dialog.CommandCategory {
return []dialog.CommandCategory{
{
Name: "Session",
Commands: []dialog.Command{
{
ID: "session.new",
Label: "New ",
Description: "Start a new conversation",
Category: "Session",
Execute: func() tea.Cmd {
a.application.NewSession()
a.chatPage = chatpage.New(a.application)
a.dialog = dialog.New()
a.statusBar = statusbar.New(a.chatPage)
return tea.Batch(a.Init(), a.handleWindowResize(a.wWidth, a.wHeight))
},
},
{
ID: "session.compact",
Label: "Compact",
Description: "Summarize the current conversation",
Category: "Session",
Execute: func() tea.Cmd {
return a.chatPage.CompactSession()
},
},
{
ID: "session.clipboard",
Label: "Copy",
Description: "Copy the current conversation to the clipboard",
Category: "Session",
Execute: func() tea.Cmd {
return a.chatPage.CopySessionToClipboard()
},
},
},
},
}
}

We should reuse them

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 {
Expand Down
Loading