Skip to content

Commit ba54353

Browse files
authored
Merge pull request #1374 from stanislavHamara/enqueue-messages
Allow user to enqueue messages
2 parents 7772759 + 7c503ec commit ba54353

File tree

8 files changed

+423
-7
lines changed

8 files changed

+423
-7
lines changed

pkg/tui/components/editor/editor.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
664664

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

676676
// Normal enter submit: send current value
677-
if value != "" && !e.working {
677+
if value != "" {
678678
cmd := e.resetAndSend(value)
679679
return e, cmd
680680
}
@@ -1181,7 +1181,7 @@ func (e *editor) IsRecording() bool {
11811181
// SendContent triggers sending the current editor content
11821182
func (e *editor) SendContent() tea.Cmd {
11831183
value := e.textarea.Value()
1184-
if value == "" || e.working {
1184+
if value == "" {
11851185
return nil
11861186
}
11871187
return e.resetAndSend(value)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package sidebar
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/cagent/pkg/tui/service"
11+
)
12+
13+
func TestQueueSection_SingleMessage(t *testing.T) {
14+
t.Parallel()
15+
16+
sessionState := &service.SessionState{}
17+
m := New(sessionState).(*model)
18+
19+
m.SetQueuedMessages([]string{"Hello world"})
20+
21+
result := m.queueSection(40)
22+
23+
// Should contain the title with count
24+
assert.Contains(t, result, "Queue (1)")
25+
26+
// Should contain the message
27+
assert.Contains(t, result, "Hello world")
28+
29+
// Should contain the clear hint
30+
assert.Contains(t, result, "Ctrl+X to clear")
31+
32+
// Should use └ prefix for single (last) item
33+
assert.Contains(t, result, "└")
34+
}
35+
36+
func TestQueueSection_MultipleMessages(t *testing.T) {
37+
t.Parallel()
38+
39+
sessionState := &service.SessionState{}
40+
m := New(sessionState).(*model)
41+
42+
m.SetQueuedMessages([]string{"First", "Second", "Third"})
43+
44+
result := m.queueSection(40)
45+
46+
// Should contain the title with count
47+
assert.Contains(t, result, "Queue (3)")
48+
49+
// Should contain all messages
50+
assert.Contains(t, result, "First")
51+
assert.Contains(t, result, "Second")
52+
assert.Contains(t, result, "Third")
53+
54+
// Should contain the clear hint
55+
assert.Contains(t, result, "Ctrl+X to clear")
56+
57+
// Should have tree-style prefixes
58+
assert.Contains(t, result, "├") // For non-last items
59+
assert.Contains(t, result, "└") // For last item
60+
}
61+
62+
func TestQueueSection_LongMessageTruncation(t *testing.T) {
63+
t.Parallel()
64+
65+
sessionState := &service.SessionState{}
66+
m := New(sessionState).(*model)
67+
68+
// Create a very long message
69+
longMessage := strings.Repeat("x", 100)
70+
m.SetQueuedMessages([]string{longMessage})
71+
72+
result := m.queueSection(30) // Narrow width to force truncation
73+
74+
// Should contain truncation indicator
75+
require.NotEmpty(t, result)
76+
77+
// The full long message should not appear (it's truncated)
78+
assert.NotContains(t, result, longMessage)
79+
}
80+
81+
func TestQueueSection_InRenderSections(t *testing.T) {
82+
t.Parallel()
83+
84+
sessionState := &service.SessionState{}
85+
m := New(sessionState).(*model)
86+
m.SetSize(40, 100) // Set a reasonable size
87+
88+
// Without queued messages, queue section should not appear in output
89+
linesWithoutQueue := m.renderSections(35)
90+
outputWithoutQueue := strings.Join(linesWithoutQueue, "\n")
91+
assert.NotContains(t, outputWithoutQueue, "Queue")
92+
93+
// With queued messages, queue section should appear
94+
m.SetQueuedMessages([]string{"Pending task"})
95+
linesWithQueue := m.renderSections(35)
96+
outputWithQueue := strings.Join(linesWithQueue, "\n")
97+
assert.Contains(t, outputWithQueue, "Queue (1)")
98+
assert.Contains(t, outputWithQueue, "Pending task")
99+
}

pkg/tui/components/sidebar/sidebar.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type Model interface {
4646
SetAgentSwitching(switching bool)
4747
SetToolsetInfo(availableTools int, loading bool)
4848
SetSessionStarred(starred bool)
49+
SetQueuedMessages(messages []string)
4950
GetSize() (width, height int)
5051
LoadFromSession(sess *session.Session)
5152
// HandleClick checks if click is on the star and returns true if handled
@@ -87,6 +88,7 @@ type model struct {
8788
workingAgent string // Name of the agent currently working (empty if none)
8889
scrollbar *scrollbar.Model
8990
workingDirectory string
91+
queuedMessages []string // Truncated preview of queued messages
9092
}
9193

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

182+
// SetQueuedMessages sets the list of queued message previews to display
183+
func (m *model) SetQueuedMessages(messages []string) {
184+
m.queuedMessages = messages
185+
}
186+
180187
// HandleClick checks if click is on the star and returns true if it was
181188
// x and y are coordinates relative to the sidebar's top-left corner
182189
// This does NOT toggle the state - caller should handle that
@@ -478,6 +485,7 @@ func (m *model) renderSections(contentWidth int) []string {
478485

479486
appendSection(m.sessionInfo(contentWidth))
480487
appendSection(m.tokenUsage(contentWidth))
488+
appendSection(m.queueSection(contentWidth))
481489
appendSection(m.agentInfo(contentWidth))
482490
appendSection(m.toolsetInfo(contentWidth))
483491

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

646+
// queueSection renders the queued messages section
647+
func (m *model) queueSection(contentWidth int) string {
648+
if len(m.queuedMessages) == 0 {
649+
return ""
650+
}
651+
652+
maxMsgWidth := contentWidth - treePrefixWidth
653+
var lines []string
654+
655+
for i, msg := range m.queuedMessages {
656+
// Determine prefix based on position
657+
var prefix string
658+
if i == len(m.queuedMessages)-1 {
659+
prefix = styles.MutedStyle.Render("└ ")
660+
} else {
661+
prefix = styles.MutedStyle.Render("├ ")
662+
}
663+
664+
// Truncate message and add prefix
665+
truncated := toolcommon.TruncateText(msg, maxMsgWidth)
666+
lines = append(lines, prefix+truncated)
667+
}
668+
669+
// Add hint for clearing
670+
lines = append(lines, styles.MutedStyle.Render(" Ctrl+X to clear"))
671+
672+
title := fmt.Sprintf("Queue (%d)", len(m.queuedMessages))
673+
return m.renderTab(title, strings.Join(lines, "\n"), contentWidth)
674+
}
675+
638676
// agentInfo renders the current agent information
639677
func (m *model) agentInfo(contentWidth int) string {
640678
// Read current agent from session state so sidebar updates when agent is switched

pkg/tui/messages/messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type (
2424
StartSpeakMsg struct{} // Start speech-to-text transcription
2525
StopSpeakMsg struct{} // Stop speech-to-text transcription
2626
SpeakTranscriptMsg struct{ Delta string } // Transcription delta from speech-to-text
27+
ClearQueueMsg struct{} // Clear all queued messages
2728
)
2829

2930
// AgentCommandMsg command message

pkg/tui/page/chat/chat.go

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/docker/cagent/pkg/tui/commands"
2121
"github.com/docker/cagent/pkg/tui/components/editor"
2222
"github.com/docker/cagent/pkg/tui/components/messages"
23+
"github.com/docker/cagent/pkg/tui/components/notification"
2324
"github.com/docker/cagent/pkg/tui/components/sidebar"
2425
"github.com/docker/cagent/pkg/tui/components/spinner"
2526
"github.com/docker/cagent/pkg/tui/core"
@@ -68,6 +69,15 @@ type Page interface {
6869
SendEditorContent() tea.Cmd
6970
}
7071

72+
// queuedMessage represents a message waiting to be sent to the agent
73+
type queuedMessage struct {
74+
content string
75+
attachments map[string]string
76+
}
77+
78+
// maxQueuedMessages is the maximum number of messages that can be queued
79+
const maxQueuedMessages = 5
80+
7181
// chatPage implements Page
7282
type chatPage struct {
7383
width, height int
@@ -87,6 +97,9 @@ type chatPage struct {
8797
msgCancel context.CancelFunc
8898
streamCancelled bool
8999

100+
// Message queue for enqueuing messages while agent is working
101+
messageQueue []queuedMessage
102+
90103
// Key map
91104
keyMap KeyMap
92105

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

316329
case editor.SendMsg:
317330
slog.Debug(msg.Content)
318-
cmd := p.processMessage(msg)
319-
return p, cmd
331+
return p.handleSendMsg(msg)
320332

321333
case messages.StreamCancelledMsg:
322334
model, cmd := p.messages.Update(msg)
@@ -329,6 +341,12 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
329341
cmds = append(cmds, p.messages.AddCancelledMessage())
330342
}
331343
cmds = append(cmds, p.messages.ScrollToBottom())
344+
345+
// Process next queued message after cancel (queue is preserved)
346+
if queueCmd := p.processNextQueuedMessage(); queueCmd != nil {
347+
cmds = append(cmds, queueCmd)
348+
}
349+
332350
return p, tea.Batch(cmds...)
333351

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

363+
case msgtypes.ClearQueueMsg:
364+
return p.handleClearQueue()
365+
345366
default:
346367
// Try to handle as a runtime event
347368
if handled, cmd := p.handleRuntimeEvent(msg); handled {
@@ -590,6 +611,87 @@ func (p *chatPage) cancelStream(showCancelMessage bool) tea.Cmd {
590611
)
591612
}
592613

614+
// handleSendMsg handles incoming messages from the editor, either processing
615+
// them immediately or queuing them if the agent is busy.
616+
func (p *chatPage) handleSendMsg(msg editor.SendMsg) (layout.Model, tea.Cmd) {
617+
// If not working, process immediately
618+
if !p.working {
619+
cmd := p.processMessage(msg)
620+
return p, cmd
621+
}
622+
623+
// If queue is full, reject the message
624+
if len(p.messageQueue) >= maxQueuedMessages {
625+
return p, notification.WarningCmd(fmt.Sprintf("Queue full (max %d messages). Please wait.", maxQueuedMessages))
626+
}
627+
628+
// Add to queue
629+
p.messageQueue = append(p.messageQueue, queuedMessage{
630+
content: msg.Content,
631+
attachments: msg.Attachments,
632+
})
633+
p.syncQueueToSidebar()
634+
635+
queueLen := len(p.messageQueue)
636+
notifyMsg := fmt.Sprintf("Message queued (%d waiting) · Ctrl+X to clear", queueLen)
637+
638+
return p, notification.InfoCmd(notifyMsg)
639+
}
640+
641+
// processNextQueuedMessage pops the next message from the queue and processes it.
642+
// Returns nil if the queue is empty.
643+
func (p *chatPage) processNextQueuedMessage() tea.Cmd {
644+
if len(p.messageQueue) == 0 {
645+
return nil
646+
}
647+
648+
// Pop the first message from the queue
649+
queued := p.messageQueue[0]
650+
p.messageQueue[0] = queuedMessage{} // zero out to allow GC
651+
p.messageQueue = p.messageQueue[1:]
652+
p.syncQueueToSidebar()
653+
654+
msg := editor.SendMsg{
655+
Content: queued.content,
656+
Attachments: queued.attachments,
657+
}
658+
659+
return p.processMessage(msg)
660+
}
661+
662+
// handleClearQueue clears all queued messages and shows a notification.
663+
func (p *chatPage) handleClearQueue() (layout.Model, tea.Cmd) {
664+
count := len(p.messageQueue)
665+
if count == 0 {
666+
return p, notification.InfoCmd("No messages queued")
667+
}
668+
669+
p.messageQueue = nil
670+
p.syncQueueToSidebar()
671+
672+
var msg string
673+
if count == 1 {
674+
msg = "Cleared 1 queued message"
675+
} else {
676+
msg = fmt.Sprintf("Cleared %d queued messages", count)
677+
}
678+
return p, notification.SuccessCmd(msg)
679+
}
680+
681+
// syncQueueToSidebar updates the sidebar with truncated previews of queued messages.
682+
func (p *chatPage) syncQueueToSidebar() {
683+
previews := make([]string, len(p.messageQueue))
684+
for i, qm := range p.messageQueue {
685+
// Take first line and limit length for preview
686+
content := strings.TrimSpace(qm.content)
687+
if idx := strings.IndexAny(content, "\n\r"); idx != -1 {
688+
content = content[:idx]
689+
}
690+
previews[i] = content
691+
}
692+
p.sidebar.SetQueuedMessages(previews)
693+
}
694+
593695
// processMessage processes a message with the runtime
594696
func (p *chatPage) processMessage(msg editor.SendMsg) tea.Cmd {
595697
if p.msgCancel != nil {
@@ -789,7 +891,20 @@ func (p *chatPage) renderResizeHandle(width int) string {
789891

790892
if p.working {
791893
// Truncate right side and append spinner (handle stays centered)
792-
suffix := " " + p.spinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render("Working…")
894+
workingText := "Working…"
895+
if queueLen := len(p.messageQueue); queueLen > 0 {
896+
workingText = fmt.Sprintf("Working… (%d queued)", queueLen)
897+
}
898+
suffix := " " + p.spinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render(workingText)
899+
suffixWidth := lipgloss.Width(suffix)
900+
truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine)
901+
return truncated + suffix
902+
}
903+
904+
// Show queue count even when not working (messages waiting to be processed)
905+
if queueLen := len(p.messageQueue); queueLen > 0 {
906+
queueText := fmt.Sprintf("%d queued", queueLen)
907+
suffix := " " + styles.WarningStyle.Render(queueText) + " "
793908
suffixWidth := lipgloss.Width(suffix)
794909
truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine)
795910
return truncated + suffix

0 commit comments

Comments
 (0)