Skip to content

Commit c4643a2

Browse files
committed
tui: introduce subscription package for external event sources
Add a subscription package that provides patterns for converting external event sources (channels, etc.) into the Bubble Tea message flow, following the Elm Architecture subscription pattern. Features: - FromChannel: Convert channel reads to tea.Cmd/tea.Msg - FromChannelWithClose: With optional close handler - ChannelSubscription: Reusable wrapper with Listen() method Updated the theme file watcher to use ChannelSubscription, making the external event source explicit and demonstrating the pattern. In Elm, subscriptions are declared at the top level and the runtime manages them. This package provides similar patterns for Go/Bubble Tea, making external event handling explicit and consistent. Assisted-By: cagent
1 parent 019bbe8 commit c4643a2

2 files changed

Lines changed: 119 additions & 31 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Package subscription provides patterns for external event sources in the TUI.
2+
//
3+
// In the Elm Architecture, subscriptions declare interest in external events
4+
// (time, WebSocket messages, etc.). This package provides similar patterns
5+
// for Go/Bubble Tea, making external event sources explicit and manageable.
6+
//
7+
// # Pattern Overview
8+
//
9+
// A subscription converts an external event source (channel, timer, etc.)
10+
// into a tea.Cmd that returns tea.Msg values. The TUI's Update function
11+
// then processes these messages like any other.
12+
//
13+
// # Example Usage
14+
//
15+
// // In your model
16+
// type model struct {
17+
// eventCh chan ExternalEvent
18+
// }
19+
//
20+
// // Create a listener that converts channel events to messages
21+
// func (m *model) listenForEvents() tea.Cmd {
22+
// return subscription.FromChannel(m.eventCh, func(e ExternalEvent) tea.Msg {
23+
// return MyEventMsg{Event: e}
24+
// })
25+
// }
26+
//
27+
// // In Init, start listening
28+
// func (m *model) Init() tea.Cmd {
29+
// return m.listenForEvents()
30+
// }
31+
//
32+
// // In Update, handle the message and re-subscribe
33+
// func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
34+
// switch msg := msg.(type) {
35+
// case MyEventMsg:
36+
// // Handle the event
37+
// // Re-subscribe to continue listening
38+
// return m, m.listenForEvents()
39+
// }
40+
// }
41+
package subscription
42+
43+
import tea "charm.land/bubbletea/v2"
44+
45+
// FromChannel creates a tea.Cmd that waits for a value from the channel
46+
// and converts it to a tea.Msg using the provided function.
47+
//
48+
// The returned Cmd blocks until a value is received or the channel is closed.
49+
// When the channel is closed, it returns nil (no message).
50+
//
51+
// To continue listening after receiving a message, call this function again
52+
// in your Update handler (re-subscription pattern).
53+
func FromChannel[T any](ch <-chan T, toMsg func(T) tea.Msg) tea.Cmd {
54+
return func() tea.Msg {
55+
val, ok := <-ch
56+
if !ok {
57+
return nil // Channel closed
58+
}
59+
return toMsg(val)
60+
}
61+
}
62+
63+
// FromChannelWithClose is like FromChannel but also calls onClose when the
64+
// channel is closed, allowing cleanup or final messages.
65+
func FromChannelWithClose[T any](ch <-chan T, toMsg func(T) tea.Msg, onClose func() tea.Msg) tea.Cmd {
66+
return func() tea.Msg {
67+
val, ok := <-ch
68+
if !ok {
69+
if onClose != nil {
70+
return onClose()
71+
}
72+
return nil
73+
}
74+
return toMsg(val)
75+
}
76+
}
77+
78+
// ChannelSubscription wraps a channel with helper methods for the
79+
// re-subscription pattern common in Bubble Tea.
80+
type ChannelSubscription[T any] struct {
81+
ch <-chan T
82+
toMsg func(T) tea.Msg
83+
}
84+
85+
// NewChannelSubscription creates a subscription for the given channel.
86+
func NewChannelSubscription[T any](ch <-chan T, toMsg func(T) tea.Msg) *ChannelSubscription[T] {
87+
return &ChannelSubscription[T]{ch: ch, toMsg: toMsg}
88+
}
89+
90+
// Listen returns a Cmd that waits for the next value from the channel.
91+
// Call this in Init() to start listening, and again in Update() after
92+
// handling each message to continue listening.
93+
func (s *ChannelSubscription[T]) Listen() tea.Cmd {
94+
return FromChannel(s.ch, s.toMsg)
95+
}

pkg/tui/tui.go

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/docker/cagent/pkg/tui/page/chat"
2929
"github.com/docker/cagent/pkg/tui/service"
3030
"github.com/docker/cagent/pkg/tui/styles"
31+
"github.com/docker/cagent/pkg/tui/subscription"
3132
)
3233

3334
// appModel represents the main application model
@@ -48,9 +49,10 @@ type appModel struct {
4849

4950
transcriber *transcribe.Transcriber
5051

51-
themeWatcher *styles.ThemeWatcher
52-
themeWatcherEventCh chan string // Channel for theme file change events (carries themeRef)
53-
themeListenerStarted bool // Guard to prevent multiple listeners
52+
// External event subscriptions (Elm Architecture pattern)
53+
themeWatcher *styles.ThemeWatcher
54+
themeSubscription *subscription.ChannelSubscription[string] // Listens for theme file changes
55+
themeSubStarted bool // Guard against multiple subscriptions
5456

5557
// keyboardEnhancements stores the last keyboard enhancements message from the terminal.
5658
// This is reapplied to new chat/editor instances when sessions are switched.
@@ -119,21 +121,24 @@ func DefaultKeyMap() KeyMap {
119121
func New(ctx context.Context, a *app.App) tea.Model {
120122
sessionState := service.NewSessionState(a.Session())
121123

122-
// Create a channel for theme file change events (carries themeRef)
124+
// Create a channel for theme file change events
123125
themeEventCh := make(chan string, 1)
124126

125127
t := &appModel{
126-
keyMap: DefaultKeyMap(),
127-
dialog: dialog.New(),
128-
notification: notification.New(),
129-
completions: completion.New(),
130-
application: a,
131-
sessionState: sessionState,
132-
transcriber: transcribe.New(os.Getenv("OPENAI_API_KEY")), // TODO(dga): should use envProvider
133-
themeWatcherEventCh: themeEventCh,
128+
keyMap: DefaultKeyMap(),
129+
dialog: dialog.New(),
130+
notification: notification.New(),
131+
completions: completion.New(),
132+
application: a,
133+
sessionState: sessionState,
134+
transcriber: transcribe.New(os.Getenv("OPENAI_API_KEY")), // TODO(dga): should use envProvider
135+
// Set up theme subscription using the subscription package
136+
themeSubscription: subscription.NewChannelSubscription(themeEventCh, func(themeRef string) tea.Msg {
137+
return messages.ThemeFileChangedMsg{ThemeRef: themeRef}
138+
}),
134139
}
135140

136-
// Create theme watcher with callback that sends themeRef to channel
141+
// Create theme watcher with callback that sends to the subscription channel
137142
t.themeWatcher = styles.NewThemeWatcher(func(themeRef string) {
138143
// Non-blocking send to the event channel
139144
select {
@@ -170,27 +175,15 @@ func (a *appModel) Init() tea.Cmd {
170175
a.application.SendFirstMessage(),
171176
}
172177

173-
// Start theme file listener only once (guard against Init being called multiple times)
174-
if !a.themeListenerStarted {
175-
a.themeListenerStarted = true
176-
cmds = append(cmds, a.listenForThemeFileChanges())
178+
// Start theme subscription only once (guard against Init being called multiple times)
179+
if !a.themeSubStarted {
180+
a.themeSubStarted = true
181+
cmds = append(cmds, a.themeSubscription.Listen())
177182
}
178183

179184
return tea.Sequence(cmds...)
180185
}
181186

182-
// listenForThemeFileChanges returns a command that listens for theme file change events
183-
// and sends them as messages to the TUI.
184-
func (a *appModel) listenForThemeFileChanges() tea.Cmd {
185-
return func() tea.Msg {
186-
themeRef, ok := <-a.themeWatcherEventCh
187-
if !ok {
188-
return nil // Channel closed
189-
}
190-
return messages.ThemeFileChangedMsg{ThemeRef: themeRef}
191-
}
192-
}
193-
194187
// Help returns help information
195188
func (a *appModel) Help() help.KeyMap {
196189
return core.NewSimpleHelp(a.Bindings())
@@ -422,14 +415,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
422415
if err != nil {
423416
// Failed to load - show error but keep current theme
424417
return a, tea.Batch(
425-
a.listenForThemeFileChanges(),
418+
a.themeSubscription.Listen(), // Re-subscribe to continue listening
426419
notification.ErrorCmd(fmt.Sprintf("Failed to hot-reload theme: %v", err)),
427420
)
428421
}
429422
styles.ApplyTheme(theme)
430423
// Continue listening for more changes and emit ThemeChangedMsg for cache invalidation
431424
return a, tea.Batch(
432-
a.listenForThemeFileChanges(),
425+
a.themeSubscription.Listen(), // Re-subscribe to continue listening
433426
notification.SuccessCmd("Theme hot-reloaded"),
434427
core.CmdHandler(messages.ThemeChangedMsg{}),
435428
)

0 commit comments

Comments
 (0)