Skip to content
Merged
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
6 changes: 3 additions & 3 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {

// If plain enter and textarea inserted a newline, submit the previous value
if value != prev && msg.String() == "enter" {
if prev != "" && !e.working {
if prev != "" {
e.textarea.SetValue(prev)
e.textarea.MoveToEnd()
cmd := e.resetAndSend(prev)
Expand All @@ -674,7 +674,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
}

// Normal enter submit: send current value
if value != "" && !e.working {
if value != "" {
cmd := e.resetAndSend(value)
return e, cmd
}
Expand Down Expand Up @@ -1181,7 +1181,7 @@ func (e *editor) IsRecording() bool {
// SendContent triggers sending the current editor content
func (e *editor) SendContent() tea.Cmd {
value := e.textarea.Value()
if value == "" || e.working {
if value == "" {
return nil
}
return e.resetAndSend(value)
Expand Down
99 changes: 99 additions & 0 deletions pkg/tui/components/sidebar/queue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package sidebar

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/cagent/pkg/tui/service"
)

func TestQueueSection_SingleMessage(t *testing.T) {
t.Parallel()

sessionState := &service.SessionState{}
m := New(sessionState).(*model)

m.SetQueuedMessages([]string{"Hello world"})

result := m.queueSection(40)

// Should contain the title with count
assert.Contains(t, result, "Queue (1)")

// Should contain the message
assert.Contains(t, result, "Hello world")

// Should contain the clear hint
assert.Contains(t, result, "Ctrl+X to clear")

// Should use └ prefix for single (last) item
assert.Contains(t, result, "└")
}

func TestQueueSection_MultipleMessages(t *testing.T) {
t.Parallel()

sessionState := &service.SessionState{}
m := New(sessionState).(*model)

m.SetQueuedMessages([]string{"First", "Second", "Third"})

result := m.queueSection(40)

// Should contain the title with count
assert.Contains(t, result, "Queue (3)")

// Should contain all messages
assert.Contains(t, result, "First")
assert.Contains(t, result, "Second")
assert.Contains(t, result, "Third")

// Should contain the clear hint
assert.Contains(t, result, "Ctrl+X to clear")

// Should have tree-style prefixes
assert.Contains(t, result, "├") // For non-last items
assert.Contains(t, result, "└") // For last item
}

func TestQueueSection_LongMessageTruncation(t *testing.T) {
t.Parallel()

sessionState := &service.SessionState{}
m := New(sessionState).(*model)

// Create a very long message
longMessage := strings.Repeat("x", 100)
m.SetQueuedMessages([]string{longMessage})

result := m.queueSection(30) // Narrow width to force truncation

// Should contain truncation indicator
require.NotEmpty(t, result)

// The full long message should not appear (it's truncated)
assert.NotContains(t, result, longMessage)
}

func TestQueueSection_InRenderSections(t *testing.T) {
t.Parallel()

sessionState := &service.SessionState{}
m := New(sessionState).(*model)
m.SetSize(40, 100) // Set a reasonable size

// Without queued messages, queue section should not appear in output
linesWithoutQueue := m.renderSections(35)
outputWithoutQueue := strings.Join(linesWithoutQueue, "\n")
assert.NotContains(t, outputWithoutQueue, "Queue")

// With queued messages, queue section should appear
m.SetQueuedMessages([]string{"Pending task"})
linesWithQueue := m.renderSections(35)
outputWithQueue := strings.Join(linesWithQueue, "\n")
assert.Contains(t, outputWithQueue, "Queue (1)")
assert.Contains(t, outputWithQueue, "Pending task")
}
38 changes: 38 additions & 0 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type Model interface {
SetAgentSwitching(switching bool)
SetToolsetInfo(availableTools int, loading bool)
SetSessionStarred(starred bool)
SetQueuedMessages(messages []string)
GetSize() (width, height int)
LoadFromSession(sess *session.Session)
// HandleClick checks if click is on the star and returns true if handled
Expand Down Expand Up @@ -87,6 +88,7 @@ type model struct {
workingAgent string // Name of the agent currently working (empty if none)
scrollbar *scrollbar.Model
workingDirectory string
queuedMessages []string // Truncated preview of queued messages
}

// Option is a functional option for configuring the sidebar.
Expand Down Expand Up @@ -177,6 +179,11 @@ func (m *model) SetSessionStarred(starred bool) {
m.sessionStarred = starred
}

// SetQueuedMessages sets the list of queued message previews to display
func (m *model) SetQueuedMessages(messages []string) {
m.queuedMessages = messages
}

// HandleClick checks if click is on the star and returns true if it was
// x and y are coordinates relative to the sidebar's top-left corner
// This does NOT toggle the state - caller should handle that
Expand Down Expand Up @@ -478,6 +485,7 @@ func (m *model) renderSections(contentWidth int) []string {

appendSection(m.sessionInfo(contentWidth))
appendSection(m.tokenUsage(contentWidth))
appendSection(m.queueSection(contentWidth))
appendSection(m.agentInfo(contentWidth))
appendSection(m.toolsetInfo(contentWidth))

Expand Down Expand Up @@ -635,6 +643,36 @@ func (m *model) sessionInfo(contentWidth int) string {
return m.renderTab("Session", strings.Join(lines, "\n"), contentWidth)
}

// queueSection renders the queued messages section
func (m *model) queueSection(contentWidth int) string {
if len(m.queuedMessages) == 0 {
return ""
}

maxMsgWidth := contentWidth - treePrefixWidth
var lines []string

for i, msg := range m.queuedMessages {
// Determine prefix based on position
var prefix string
if i == len(m.queuedMessages)-1 {
prefix = styles.MutedStyle.Render("└ ")
} else {
prefix = styles.MutedStyle.Render("├ ")
}

// Truncate message and add prefix
truncated := toolcommon.TruncateText(msg, maxMsgWidth)
lines = append(lines, prefix+truncated)
}

// Add hint for clearing
lines = append(lines, styles.MutedStyle.Render(" Ctrl+X to clear"))

title := fmt.Sprintf("Queue (%d)", len(m.queuedMessages))
return m.renderTab(title, strings.Join(lines, "\n"), contentWidth)
}

// agentInfo renders the current agent information
func (m *model) agentInfo(contentWidth int) string {
// Read current agent from session state so sidebar updates when agent is switched
Expand Down
1 change: 1 addition & 0 deletions pkg/tui/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type (
StartSpeakMsg struct{} // Start speech-to-text transcription
StopSpeakMsg struct{} // Stop speech-to-text transcription
SpeakTranscriptMsg struct{ Delta string } // Transcription delta from speech-to-text
ClearQueueMsg struct{} // Clear all queued messages
)

// AgentCommandMsg command message
Expand Down
121 changes: 118 additions & 3 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/docker/cagent/pkg/tui/commands"
"github.com/docker/cagent/pkg/tui/components/editor"
"github.com/docker/cagent/pkg/tui/components/messages"
"github.com/docker/cagent/pkg/tui/components/notification"
"github.com/docker/cagent/pkg/tui/components/sidebar"
"github.com/docker/cagent/pkg/tui/components/spinner"
"github.com/docker/cagent/pkg/tui/core"
Expand Down Expand Up @@ -68,6 +69,15 @@ type Page interface {
SendEditorContent() tea.Cmd
}

// queuedMessage represents a message waiting to be sent to the agent
type queuedMessage struct {
content string
attachments map[string]string
}

// maxQueuedMessages is the maximum number of messages that can be queued
const maxQueuedMessages = 5

// chatPage implements Page
type chatPage struct {
width, height int
Expand All @@ -87,6 +97,9 @@ type chatPage struct {
msgCancel context.CancelFunc
streamCancelled bool

// Message queue for enqueuing messages while agent is working
messageQueue []queuedMessage

// Key map
keyMap KeyMap

Expand Down Expand Up @@ -315,8 +328,7 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {

case editor.SendMsg:
slog.Debug(msg.Content)
cmd := p.processMessage(msg)
return p, cmd
return p.handleSendMsg(msg)

case messages.StreamCancelledMsg:
model, cmd := p.messages.Update(msg)
Expand All @@ -329,6 +341,12 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
cmds = append(cmds, p.messages.AddCancelledMessage())
}
cmds = append(cmds, p.messages.ScrollToBottom())

// Process next queued message after cancel (queue is preserved)
if queueCmd := p.processNextQueuedMessage(); queueCmd != nil {
cmds = append(cmds, queueCmd)
}

return p, tea.Batch(cmds...)

case msgtypes.InsertFileRefMsg:
Expand All @@ -342,6 +360,9 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
p.messages = model.(messages.Model)
return p, cmd

case msgtypes.ClearQueueMsg:
return p.handleClearQueue()

default:
// Try to handle as a runtime event
if handled, cmd := p.handleRuntimeEvent(msg); handled {
Expand Down Expand Up @@ -590,6 +611,87 @@ func (p *chatPage) cancelStream(showCancelMessage bool) tea.Cmd {
)
}

// handleSendMsg handles incoming messages from the editor, either processing
// them immediately or queuing them if the agent is busy.
func (p *chatPage) handleSendMsg(msg editor.SendMsg) (layout.Model, tea.Cmd) {
// If not working, process immediately
if !p.working {
cmd := p.processMessage(msg)
return p, cmd
}

// If queue is full, reject the message
if len(p.messageQueue) >= maxQueuedMessages {
return p, notification.WarningCmd(fmt.Sprintf("Queue full (max %d messages). Please wait.", maxQueuedMessages))
}

// Add to queue
p.messageQueue = append(p.messageQueue, queuedMessage{
content: msg.Content,
attachments: msg.Attachments,
})
p.syncQueueToSidebar()

queueLen := len(p.messageQueue)
notifyMsg := fmt.Sprintf("Message queued (%d waiting) · Ctrl+X to clear", queueLen)

return p, notification.InfoCmd(notifyMsg)
}

// processNextQueuedMessage pops the next message from the queue and processes it.
// Returns nil if the queue is empty.
func (p *chatPage) processNextQueuedMessage() tea.Cmd {
if len(p.messageQueue) == 0 {
return nil
}

// Pop the first message from the queue
queued := p.messageQueue[0]
p.messageQueue[0] = queuedMessage{} // zero out to allow GC
p.messageQueue = p.messageQueue[1:]
p.syncQueueToSidebar()

msg := editor.SendMsg{
Content: queued.content,
Attachments: queued.attachments,
}

return p.processMessage(msg)
}

// handleClearQueue clears all queued messages and shows a notification.
func (p *chatPage) handleClearQueue() (layout.Model, tea.Cmd) {
count := len(p.messageQueue)
if count == 0 {
return p, notification.InfoCmd("No messages queued")
}

p.messageQueue = nil
p.syncQueueToSidebar()

var msg string
if count == 1 {
msg = "Cleared 1 queued message"
} else {
msg = fmt.Sprintf("Cleared %d queued messages", count)
}
return p, notification.SuccessCmd(msg)
}

// syncQueueToSidebar updates the sidebar with truncated previews of queued messages.
func (p *chatPage) syncQueueToSidebar() {
previews := make([]string, len(p.messageQueue))
for i, qm := range p.messageQueue {
// Take first line and limit length for preview
content := strings.TrimSpace(qm.content)
if idx := strings.IndexAny(content, "\n\r"); idx != -1 {
content = content[:idx]
}
previews[i] = content
}
p.sidebar.SetQueuedMessages(previews)
}

// processMessage processes a message with the runtime
func (p *chatPage) processMessage(msg editor.SendMsg) tea.Cmd {
if p.msgCancel != nil {
Expand Down Expand Up @@ -789,7 +891,20 @@ func (p *chatPage) renderResizeHandle(width int) string {

if p.working {
// Truncate right side and append spinner (handle stays centered)
suffix := " " + p.spinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render("Working…")
workingText := "Working…"
if queueLen := len(p.messageQueue); queueLen > 0 {
workingText = fmt.Sprintf("Working… (%d queued)", queueLen)
}
suffix := " " + p.spinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render(workingText)
suffixWidth := lipgloss.Width(suffix)
truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine)
return truncated + suffix
}

// Show queue count even when not working (messages waiting to be processed)
if queueLen := len(p.messageQueue); queueLen > 0 {
queueText := fmt.Sprintf("%d queued", queueLen)
suffix := " " + styles.WarningStyle.Render(queueText) + " "
suffixWidth := lipgloss.Width(suffix)
truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine)
return truncated + suffix
Expand Down
Loading