Skip to content

Commit dc243db

Browse files
halfaipgclaude
andcommitted
feat: add Glue LLM sidecar with notification/animation layer
- GlueClient with env-configurable fast/smart models (GLUE_API_KEY, GLUE_BASE_URL, GLUE_FAST_MODEL, GLUE_SMART_MODEL) — falls back to OPENAI_* vars so it works out of the box - Intent classification routes user input: agent (tools needed), chat (answer directly), clarify (ask for details) - Session title generation from first prompt - Progress narration during long agent runs (every ~15s) - Celebration messages on task completion - Follow-up suggestions after agent finishes - Toast notification system with fade-in/out animation, type-specific colors and icons (info/progress/success/warn/celebrate) - Sparkle animation for celebration toasts - Progress notifications auto-replace (only one at a time) - Suggestions bar rendered below the frame - 18 new tests (60 total) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1388ac4 commit dc243db

5 files changed

Lines changed: 1079 additions & 8 deletions

File tree

chat.go

Lines changed: 207 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,26 @@ type chatModel struct {
5757
stopCh chan struct{}
5858
agent *Agent
5959
flashFrames int
60+
61+
// Glue + notifications
62+
glue *GlueClient
63+
notify *notifyManager
64+
title string // session title from glue
65+
suggestions []string // follow-up suggestions
66+
recentActions []string // recent tool actions for narration
67+
lastNarration time.Time
6068
}
6169

6270
// Messages
6371
type agentEventMsg AgentEvent
6472
type flashTickMsg struct{}
73+
type narrateTickMsg struct{}
74+
type notifyTickMsg struct{}
75+
type glueResultMsg struct {
76+
kind string // "chat", "clarify", "title", "celebrate", "suggest"
77+
text string
78+
suggestions []string
79+
}
6580

6681
func newChatModel(cfg *Config) chatModel {
6782
ti := textinput.New()
@@ -91,11 +106,17 @@ func newChatModel(cfg *Config) chatModel {
91106
state: chatIdle,
92107
segments: welcome,
93108
streaming: &strings.Builder{},
109+
glue: NewGlueClient(cfg),
110+
notify: newNotifyManager(),
94111
}
95112
}
96113

97114
func (m chatModel) Init() tea.Cmd {
98-
return tea.Batch(textinput.Blink, m.spinner.Tick)
115+
return tea.Batch(
116+
textinput.Blink,
117+
m.spinner.Tick,
118+
tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return notifyTickMsg{} }),
119+
)
99120
}
100121

101122
func (m chatModel) waitForEvent() tea.Cmd {
@@ -147,13 +168,134 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) {
147168
return m, nil
148169
}
149170
m.input.SetValue("")
150-
m.startAgent(prompt)
151-
cmds = append(cmds, m.waitForEvent())
171+
m.suggestions = nil // clear old suggestions
172+
173+
// Route through glue intent classification
174+
hasHistory := m.agent != nil
175+
intent := m.glue.ClassifyIntent(prompt, hasHistory)
176+
177+
switch intent {
178+
case IntentChat:
179+
// Show user message
180+
m.segments = append(m.segments, segment{
181+
kind: "user",
182+
text: "\n" + styleUserLabel.Render(" ❯ ") + prompt + "\n\n",
183+
})
184+
// Get reply in background
185+
glue := m.glue
186+
cmds = append(cmds, func() tea.Msg {
187+
reply := glue.ChatReply(prompt, nil)
188+
return glueResultMsg{kind: "chat", text: reply}
189+
})
190+
191+
case IntentClarify:
192+
m.segments = append(m.segments, segment{
193+
kind: "user",
194+
text: "\n" + styleUserLabel.Render(" ❯ ") + prompt + "\n\n",
195+
})
196+
glue := m.glue
197+
cmds = append(cmds, func() tea.Msg {
198+
reply := glue.ClarifyReply(prompt)
199+
return glueResultMsg{kind: "clarify", text: reply}
200+
})
201+
202+
default: // IntentAgent
203+
m.startAgent(prompt)
204+
cmds = append(cmds, m.waitForEvent())
205+
// Start narration ticker
206+
cmds = append(cmds, tea.Tick(10*time.Second, func(t time.Time) tea.Msg {
207+
return narrateTickMsg{}
208+
}))
209+
// Generate title in background (first prompt only)
210+
if m.title == "" {
211+
glue := m.glue
212+
cmds = append(cmds, func() tea.Msg {
213+
title := glue.GenerateTitle(prompt)
214+
return glueResultMsg{kind: "title", text: title}
215+
})
216+
}
217+
m.notify.Push(Notification{
218+
Type: NotifyInfo,
219+
Text: "Starting agent...",
220+
})
221+
}
152222
}
153223

154224
case agentEventMsg:
155225
cmds = append(cmds, m.handleAgentEvent(AgentEvent(msg)))
156226

227+
case glueResultMsg:
228+
switch msg.kind {
229+
case "chat", "clarify":
230+
m.segments = append(m.segments, segment{
231+
kind: "text",
232+
text: func() string {
233+
wrapped := wrapText(msg.text, m.width-8)
234+
var sb strings.Builder
235+
for _, line := range strings.Split(wrapped, "\n") {
236+
sb.WriteString(" " + line + "\n")
237+
}
238+
return sb.String()
239+
}(),
240+
})
241+
m.rebuildViewport()
242+
243+
case "title":
244+
if msg.text != "" {
245+
m.title = msg.text
246+
}
247+
248+
case "narrate":
249+
if msg.text != "" {
250+
m.notify.Push(Notification{
251+
Type: NotifyProgress,
252+
Text: msg.text,
253+
})
254+
}
255+
256+
case "celebrate":
257+
if msg.text != "" {
258+
m.notify.Push(Notification{
259+
Type: NotifyCelebrate,
260+
Text: msg.text,
261+
})
262+
}
263+
264+
case "suggest":
265+
m.suggestions = msg.suggestions
266+
m.rebuildViewport()
267+
}
268+
269+
case narrateTickMsg:
270+
if m.state == chatStreaming && len(m.recentActions) > 0 &&
271+
time.Since(m.lastNarration) > 12*time.Second {
272+
m.lastNarration = time.Now()
273+
actions := make([]string, len(m.recentActions))
274+
copy(actions, m.recentActions)
275+
glue := m.glue
276+
cmds = append(cmds, func() tea.Msg {
277+
narration := glue.Narrate(actions)
278+
if narration != "" {
279+
return glueResultMsg{kind: "narrate", text: narration}
280+
}
281+
return nil
282+
})
283+
}
284+
if m.state == chatStreaming {
285+
cmds = append(cmds, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
286+
return narrateTickMsg{}
287+
}))
288+
}
289+
290+
case notifyTickMsg:
291+
m.notify.Tick()
292+
if m.notify.HasActive() {
293+
m.rebuildViewport()
294+
}
295+
cmds = append(cmds, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
296+
return notifyTickMsg{}
297+
}))
298+
157299
case flashTickMsg:
158300
m.flashFrames--
159301
if m.flashFrames <= 0 {
@@ -248,6 +390,8 @@ func (m *chatModel) startAgent(prompt string) {
248390
m.stopCh = make(chan struct{})
249391
m.streaming.Reset()
250392
m.turns = 0
393+
m.recentActions = nil
394+
m.lastNarration = time.Now() // don't narrate immediately
251395

252396
m.segments = append(m.segments, segment{
253397
kind: "user",
@@ -309,6 +453,24 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
309453
state: "pending",
310454
},
311455
})
456+
// Track for narration
457+
action := evt.Tool
458+
if evt.Args != nil {
459+
if p, ok := evt.Args["path"]; ok {
460+
if s, ok := p.(string); ok {
461+
action += " " + s
462+
}
463+
}
464+
if p, ok := evt.Args["command"]; ok {
465+
if s, ok := p.(string); ok {
466+
action += " " + s
467+
}
468+
}
469+
}
470+
m.recentActions = append(m.recentActions, action)
471+
if len(m.recentActions) > 8 {
472+
m.recentActions = m.recentActions[len(m.recentActions)-8:]
473+
}
312474
m.rebuildViewport()
313475
return m.waitForEvent()
314476

@@ -335,6 +497,7 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
335497

336498
case EventDone:
337499
m.flushStreamingText()
500+
m.notify.ClearProgress()
338501
m.files = 0
339502
if m.agent != nil {
340503
m.files = m.agent.FilesChanged()
@@ -344,9 +507,26 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
344507
m.state = chatDoneFlash
345508
m.flashFrames = 3
346509
m.rebuildViewport()
347-
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
348-
return flashTickMsg{}
349-
})
510+
511+
// Glue: celebration + follow-up suggestions (in background)
512+
summary := evt.Text
513+
files := m.files
514+
glue := m.glue
515+
celebrateCmd := func() tea.Msg {
516+
msg := glue.Celebrate(summary)
517+
return glueResultMsg{kind: "celebrate", text: msg}
518+
}
519+
suggestCmd := func() tea.Msg {
520+
suggestions := glue.SuggestFollowUps(summary, files)
521+
return glueResultMsg{kind: "suggest", suggestions: suggestions}
522+
}
523+
524+
return tea.Batch(
525+
tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { return flashTickMsg{} }),
526+
celebrateCmd,
527+
suggestCmd,
528+
tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return narrateTickMsg{} }),
529+
)
350530

351531
case EventError:
352532
m.flushStreamingText()
@@ -404,14 +584,21 @@ func (m chatModel) View() string {
404584
statusParts = append(statusParts, m.spinner.View()+styleMuted.Render(" working"))
405585
}
406586
statusRight := strings.Join(statusParts, styleDim.Render(" │ "))
587+
407588
titleLeft := styleAccentText.Render(" codebase")
589+
if m.title != "" {
590+
titleLeft += styleDim.Render(" · ") + styleMuted.Render(m.title)
591+
}
408592

409593
gap := m.width - lipgloss.Width(titleLeft) - lipgloss.Width(statusRight) - 6
410594
if gap < 1 {
411595
gap = 1
412596
}
413597
header := titleLeft + strings.Repeat(" ", gap) + statusRight
414598

599+
// ── Notifications ────────────────────────────────────────
600+
notifyBar := m.notify.Render(m.width)
601+
415602
// ── Body ─────────────────────────────────────────────────
416603
body := m.viewport.View()
417604

@@ -426,7 +613,19 @@ func (m chatModel) View() string {
426613
frame = styleFrame.Width(m.width - 2)
427614
}
428615

429-
framedBody := frame.Render(header + "\n" + body)
616+
var innerContent string
617+
if notifyBar != "" {
618+
innerContent = header + "\n" + notifyBar + body
619+
} else {
620+
innerContent = header + "\n" + body
621+
}
622+
framedBody := frame.Render(innerContent)
623+
624+
// ── Suggestions ──────────────────────────────────────────
625+
suggestBar := ""
626+
if len(m.suggestions) > 0 && m.state == chatIdle {
627+
suggestBar = renderSuggestions(m.suggestions, m.width) + "\n"
628+
}
430629

431630
// ── Input ────────────────────────────────────────────────
432631
inputLine := " " + m.input.View()
@@ -441,7 +640,7 @@ func (m chatModel) View() string {
441640
}
442641
inputRow := inputLine + strings.Repeat(" ", inputGap) + hint
443642

444-
return framedBody + "\n" + inputRow
643+
return framedBody + "\n" + suggestBar + inputRow
445644
}
446645

447646
var styleAccentText = lipgloss.NewStyle().

0 commit comments

Comments
 (0)