on main help sign in
feat: steering (#605)

* feat: enable steering the model while in the loop * feat: show queued messages in the TUI

commitb550c69b567dee1be4f43a4b6213caf16309cd8a authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 12 Aug 2025, 04:38 (1 year ago) parentafbdba1
6 files changed+146−34
internal/llm/agent/agent.go+61−1 internal/tui/components/chat/chat.go+18−1 internal/tui/components/chat/editor/editor.go−7 internal/tui/components/chat/queue.go+28 internal/tui/page/chat/chat.go+10 internal/tui/styles/theme.go+29−25
internal/llm/agent/agent.go+61−1
@@ -60,6 +60,8 @@ type Service interface {
6060 IsBusy() bool
6161 Summarize(ctx context.Context, sessionID string) error
6262 UpdateModel() error
63+ QueuedPrompts(sessionID string) int
64+ ClearQueue(sessionID string)
6365}
6466
6567type agent struct {
@@ -79,6 +81,8 @@ type agent struct {
7981 summarizeProviderID string
8082
8183 activeRequests *csync.Map[string, context.CancelFunc]
84+
85+ promptQueue *csync.Map[string, []string]
8286}
8387
8488var agentPromptMap = map[string]prompt.PromptID{
@@ -228,6 +232,7 @@ func NewAgent(
228232 summarizeProviderID: string(providerCfg.ID),
229233 activeRequests: csync.NewMap[string, context.CancelFunc](),
230234 tools: csync.NewLazySlice(toolFn),
235+ promptQueue: csync.NewMap[string, []string](),
231236 }, nil
232237}
233238
@@ -247,6 +252,11 @@ func (a *agent) Cancel(sessionID string) {
247252 slog.Info("Summarize cancellation initiated", "session_id", sessionID)
248253 cancel()
249254 }
255+
256+ if a.QueuedPrompts(sessionID) > 0 {
257+ slog.Info("Clearing queued prompts", "session_id", sessionID)
258+ a.promptQueue.Del(sessionID)
259+ }
250260}
251261
252262func (a *agent) IsBusy() bool {
@@ -265,6 +275,14 @@ func (a *agent) IsSessionBusy(sessionID string) bool {
265275 return busy
266276}
267277
278+func (a *agent) QueuedPrompts(sessionID string) int {
279+ l, ok := a.promptQueue.Get(sessionID)
280+ if !ok {
281+ return 0
282+ }
283+ return len(l)
284+}
285+
268286func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
269287 if content == "" {
270288 return nil
@@ -327,7 +345,13 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
327345 }
328346 events := make(chan AgentEvent)
329347 if a.IsSessionBusy(sessionID) {
330− return nil, ErrSessionBusy
348+ existing, ok := a.promptQueue.Get(sessionID)
349+ if !ok {
350+ existing = []string{}
351+ }
352+ existing = append(existing, content)
353+ a.promptQueue.Set(sessionID, existing)
354+ return nil, nil
331355 }
332356
333357 genCtx, cancel := context.WithCancel(ctx)
@@ -422,7 +446,36 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
422446 if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
423447 // We are not done, we need to respond with the tool response
424448 msgHistory = append(msgHistory, agentMessage, *toolResults)
449+ // If there are queued prompts, process the next one
450+ nextPrompt, ok := a.promptQueue.Take(sessionID)
451+ if ok {
452+ for _, prompt := range nextPrompt {
453+ // Create a new user message for the queued prompt
454+ userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
455+ if err != nil {
456+ return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
457+ }
458+ // Append the new user message to the conversation history
459+ msgHistory = append(msgHistory, userMsg)
460+ }
461+ }
462+
425463 continue
464+ } else if agentMessage.FinishReason() == message.FinishReasonEndTurn {
465+ queuePrompts, ok := a.promptQueue.Take(sessionID)
466+ if ok {
467+ for _, prompt := range queuePrompts {
468+ if prompt == "" {
469+ continue
470+ }
471+ userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
472+ if err != nil {
473+ return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
474+ }
475+ msgHistory = append(msgHistory, userMsg)
476+ }
477+ continue
478+ }
426479 }
427480 if agentMessage.FinishReason() == "" {
428481 // Kujtim: could not track down where this is happening but this means its cancelled
@@ -852,6 +905,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
852905 return nil
853906}
854907
908+func (a *agent) ClearQueue(sessionID string) {
909+ if a.QueuedPrompts(sessionID) > 0 {
910+ slog.Info("Clearing queued prompts", "session_id", sessionID)
911+ a.promptQueue.Del(sessionID)
912+ }
913+}
914+
855915func (a *agent) CancelAll() {
856916 if !a.IsBusy() {
857917 return
internal/tui/components/chat/chat.go+18−1
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/tui/exp/list"
1919 "github.com/charmbracelet/crush/internal/tui/styles"
2020 "github.com/charmbracelet/crush/internal/tui/util"
21+ "github.com/charmbracelet/lipgloss/v2"
2122)
2223
2324type SendMsg struct {
@@ -198,13 +199,29 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
198199// View renders the message list or an initial screen if empty.
199200func (m *messageListCmp) View() string {
200201 t := styles.CurrentTheme()
201− return t.S().Base.
202+ listView := t.S().Base.
202203 Padding(1, 1, 0, 1).
203204 Width(m.width).
204205 Height(m.height).
205206 Render(
206207 m.listCmp.View(),
207208 )
209+
210+ if m.app.CoderAgent != nil && m.app.CoderAgent.QueuedPrompts(m.session.ID) > 0 {
211+ queue := m.app.CoderAgent.QueuedPrompts(m.session.ID)
212+ queuePill := queuePill(queue, t)
213+ layers := []*lipgloss.Layer{
214+ lipgloss.NewLayer(listView),
215+ lipgloss.NewLayer(
216+ queuePill,
217+ ).X(4).Y(m.height - 3),
218+ }
219+ canvas := lipgloss.NewCanvas(
220+ layers...,
221+ )
222+ return canvas.Render()
223+ }
224+ return listView
208225}
209226
210227func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
internal/tui/components/chat/editor/editor.go−7
@@ -138,13 +138,6 @@ func (m *editorCmp) Init() tea.Cmd {
138138}
139139
140140func (m *editorCmp) send() tea.Cmd {
141− if m.app.CoderAgent == nil {
142− return util.ReportError(fmt.Errorf("coder agent is not initialized"))
143− }
144− if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
145− return util.ReportWarn("Agent is working, please wait...")
146− }
147−
148141 value := m.textarea.Value()
149142 value = strings.TrimSpace(value)
150143
internal/tui/components/chat/queue.gonew file+28
@@ -0,0 +1,28 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/tui/styles"
8+ "github.com/charmbracelet/lipgloss/v2"
9+)
10+
11+func queuePill(queue int, t *styles.Theme) string {
12+ if queue <= 0 {
13+ return ""
14+ }
15+ triangles := styles.ForegroundGrad("▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Accent)
16+ if queue < 10 {
17+ triangles = triangles[:queue]
18+ }
19+
20+ allTriangles := strings.Join(triangles, "")
21+
22+ return t.S().Base.
23+ BorderStyle(lipgloss.RoundedBorder()).
24+ BorderForeground(t.BgOverlay).
25+ PaddingLeft(1).
26+ PaddingRight(1).
27+ Render(fmt.Sprintf("%s %d Queued", allTriangles, queue))
28+}
internal/tui/page/chat/chat.go+10
@@ -653,6 +653,10 @@ func (p *chatPage) cancel() tea.Cmd {
653653 return nil
654654 }
655655
656+ if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
657+ p.app.CoderAgent.ClearQueue(p.session.ID)
658+ return nil
659+ }
656660 p.isCanceling = true
657661 return cancelTimerCmd()
658662}
@@ -828,6 +832,12 @@ func (p *chatPage) Help() help.KeyMap {
828832 key.WithHelp("esc", "press again to cancel"),
829833 )
830834 }
835+ if p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
836+ cancelBinding = key.NewBinding(
837+ key.WithKeys("esc"),
838+ key.WithHelp("esc", "clear queue"),
839+ )
840+ }
831841 shortList = append(shortList, cancelBinding)
832842 fullList = append(fullList,
833843 []key.Binding{
internal/tui/styles/theme.go+29−25
@@ -591,18 +591,18 @@ func Lighten(c color.Color, percent float64) color.Color {
591591 }
592592}
593593
594−// ApplyForegroundGrad renders a given string with a horizontal gradient
595−// foreground.
596−func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
594+func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string {
597595 if input == "" {
598− return ""
596+ return []string{""}
599597 }
600−
601− var o strings.Builder
598+ t := CurrentTheme()
602599 if len(input) == 1 {
603− return lipgloss.NewStyle().Foreground(color1).Render(input)
600+ style := t.S().Base.Foreground(color1)
601+ if bold {
602+ style.Bold(true)
603+ }
604+ return []string{style.Render(input)}
604605 }
605−
606606 var clusters []string
607607 gr := uniseg.NewGraphemes(input)
608608 for gr.Next() {
@@ -611,9 +611,26 @@ func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
611611
612612 ramp := blendColors(len(clusters), color1, color2)
613613 for i, c := range ramp {
614− fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
614+ style := t.S().Base.Foreground(c)
615+ if bold {
616+ style.Bold(true)
617+ }
618+ clusters[i] = style.Render(clusters[i])
615619 }
620+ return clusters
621+}
616622
623+// ApplyForegroundGrad renders a given string with a horizontal gradient
624+// foreground.
625+func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
626+ if input == "" {
627+ return ""
628+ }
629+ var o strings.Builder
630+ clusters := ForegroundGrad(input, false, color1, color2)
631+ for _, c := range clusters {
632+ fmt.Fprint(&o, c)
633+ }
617634 return o.String()
618635}
619636
@@ -623,24 +640,11 @@ func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
623640 if input == "" {
624641 return ""
625642 }
626− t := CurrentTheme()
627−
628643 var o strings.Builder
629− if len(input) == 1 {
630− return t.S().Base.Bold(true).Foreground(color1).Render(input)
631− }
632−
633− var clusters []string
634− gr := uniseg.NewGraphemes(input)
635− for gr.Next() {
636− clusters = append(clusters, string(gr.Runes()))
637− }
638−
639− ramp := blendColors(len(clusters), color1, color2)
640− for i, c := range ramp {
641− fmt.Fprint(&o, t.S().Base.Bold(true).Foreground(c).Render(clusters[i]))
644+ clusters := ForegroundGrad(input, true, color1, color2)
645+ for _, c := range clusters {
646+ fmt.Fprint(&o, c)
642647 }
643−
644648 return o.String()
645649}
646650