From 79226e9a3674e685471803e00fff42712ab74c58 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 30 Jul 2026 14:25:08 +0300 Subject: [PATCH 1/2] refactor(app): type event fan-out with sealed Event union (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app layer described its own events with Bubble Tea's untyped tea.Msg, forcing internal/app to speak the TUI's message vocabulary and letting any value flow through the event-producing paths unchecked. Introduce a sealed app.Event interface (unexported isAppEvent marker) implemented by every event type in events.go, and retype all app-internal fan-out — RunOnceWithDisplay(AndFiles), executeStep, executeBatch, runQueueBatch, subscribeSDKEvents, handleTurnEnd, recordStepUsage and the sendEvent helper — from func(tea.Msg) to func(Event). The CLI handler's Handle now takes app.Event, dropping its bubbletea import entirely. Only the concrete transport (prog.Send) adapts Event at the boundary, so the event-producing code is now transport-agnostic. Rename the UI-transport escape hatch AppController.SendEvent(tea.Msg) to SendUIMessage(tea.Msg) so it is clearly distinct from app events: it re-injects UI-internal messages (async goroutine results) into the Update loop and stays opaque to the app layer, which never interprets it. Response-channel events (password/prompt/overlay/new-session) keep their channels for now; a request-id correlation scheme for a real out-of-process transport is deferred until such a transport exists. Refs #101 --- internal/app/app.go | 45 ++++++++++++++---------- internal/app/app_test.go | 7 ++-- internal/app/events.go | 66 ++++++++++++++++++++++++++++++++++++ internal/ui/event_handler.go | 4 +-- internal/ui/model.go | 22 ++++++------ internal/ui/model_test.go | 2 +- 6 files changed, 111 insertions(+), 35 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 76a08ccb..be72bf20 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -837,13 +837,13 @@ func (a *App) RunOnceResultWithFiles(ctx context.Context, prompt string, files [ // etc.) and is responsible for rendering them. // // Blocks until the step completes or ctx is cancelled. -func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn func(tea.Msg)) error { +func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn func(Event)) error { return a.RunOnceWithDisplayAndFiles(ctx, prompt, eventFn, nil) } // RunOnceWithDisplayAndFiles executes a single agent step synchronously with // optional multimodal file attachments, sending intermediate display events. -func (a *App) RunOnceWithDisplayAndFiles(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) error { +func (a *App) RunOnceWithDisplayAndFiles(ctx context.Context, prompt string, eventFn func(Event), files []kit.LLMFilePart) error { stepCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -995,7 +995,7 @@ func (a *App) runQueueBatch(items []queueItem) { prog := a.program a.mu.Unlock() - eventFn := func(msg tea.Msg) { + eventFn := func(msg Event) { if prog != nil { prog.Send(msg) } @@ -1029,13 +1029,13 @@ func (a *App) runQueueBatch(items []queueItem) { // executeStep runs a single agentic step by delegating to the SDK's // PromptResult() (or PromptResultWithFiles for multimodal), which handles // session persistence, hooks, extension events, and the generation loop. -func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) { +func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(Event), files []kit.LLMFilePart) (*kit.TurnResult, error) { // Test hook: bypass SDK entirely. if a.opts.PromptFunc != nil { return a.opts.PromptFunc(ctx, prompt) } - sendFn := func(msg tea.Msg) { + sendFn := func(msg Event) { if eventFn != nil { eventFn(msg) } @@ -1080,7 +1080,7 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M // executeBatch runs a batch of queue items as a single agent step by delegating // to the SDK's PromptResultWithMessages(), which handles session persistence, // hooks, extension events, and the generation loop. -func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(tea.Msg)) (*kit.TurnResult, error) { +func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(Event)) (*kit.TurnResult, error) { // Test hook: bypass SDK entirely (single item only for test compatibility). if a.opts.PromptFunc != nil { if len(items) == 1 { @@ -1090,7 +1090,7 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func( return a.opts.PromptFunc(ctx, items[0].Prompt) } - sendFn := func(msg tea.Msg) { + sendFn := func(msg Event) { if eventFn != nil { eventFn(msg) } @@ -1168,14 +1168,14 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func( return result, nil } -// sendEvent sends a tea.Msg to the registered program if one is set. +// sendEvent sends an app Event to the registered program if one is set. // Must NOT be called with a.mu held (to avoid deadlock with the program). -func (a *App) sendEvent(msg tea.Msg) { +func (a *App) sendEvent(e Event) { a.mu.Lock() prog := a.program a.mu.Unlock() if prog != nil { - prog.Send(msg) + prog.Send(e) } } @@ -1187,7 +1187,7 @@ func (a *App) sendEvent(msg tea.Msg) { // false, such events are answered "cancelled" immediately instead of being // dispatched — dispatching into a void would leave the SDK blocked forever. // Returns an unsubscribe function that removes all listeners. -func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool, canPrompt bool) func() { +func (a *App) subscribeSDKEvents(sendFn func(Event), stepUsageSeen *atomic.Bool, canPrompt bool) func() { k := a.opts.Kit var unsubs []func() @@ -1281,7 +1281,7 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo // // Separated from subscribeSDKEvents so tests can exercise it directly via a // stubbed sendFn without standing up a full Kit. -func (a *App) handleTurnEnd(ev kit.TurnEndEvent, sendFn func(tea.Msg)) { +func (a *App) handleTurnEnd(ev kit.TurnEndEvent, sendFn func(Event)) { if sendFn == nil { return } @@ -1530,12 +1530,23 @@ func (a *App) NotifyMCPServerLoaded(serverName string, toolCount int, err error) } } -// SendEvent sends a tea.Msg to the registered program. Safe to call from -// any goroutine. No-op when no program is registered. +// SendUIMessage forwards a display-layer message straight to the registered +// program. Safe to call from any goroutine; a no-op when no program is +// registered. +// +// Unlike the app's own Event fan-out, this is a transport escape hatch for the +// UI to re-inject its own internal messages (goroutine results that must reach +// the Bubble Tea Update loop without going through a stalling tea.Cmd). The app +// treats the payload as opaque and does not interpret it. // // Satisfies ui.AppController. -func (a *App) SendEvent(msg tea.Msg) { - a.sendEvent(msg) +func (a *App) SendUIMessage(msg tea.Msg) { + a.mu.Lock() + prog := a.program + a.mu.Unlock() + if prog != nil { + prog.Send(msg) + } } // SendPromptRequest sends a PromptRequestEvent to the TUI so the user can @@ -1635,7 +1646,7 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) { // // sendFn is called with a UsageUpdatedEvent to trigger a TUI re-render so // the updated values are visible immediately. -func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool, sendFn func(tea.Msg)) { +func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool, sendFn func(Event)) { hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0 if a.opts.Debug { log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d", diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1b9666e4..74ee8dd1 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - tea "charm.land/bubbletea/v2" "charm.land/fantasy" kit "github.com/mark3labs/kit/pkg/kit" @@ -762,8 +761,8 @@ func TestHandleTurnEnd_LengthEmitsWarning(t *testing.T) { defer app.Close() var mu sync.Mutex - var received []tea.Msg - sendFn := func(m tea.Msg) { + var received []Event + sendFn := func(m Event) { mu.Lock() defer mu.Unlock() received = append(received, m) @@ -808,7 +807,7 @@ func TestHandleTurnEnd_NonLengthIgnored(t *testing.T) { } for _, r := range reasons { var called bool - app.handleTurnEnd(kit.TurnEndEvent{StopReason: r}, func(m tea.Msg) { + app.handleTurnEnd(kit.TurnEndEvent{StopReason: r}, func(m Event) { called = true }) if called { diff --git a/internal/app/events.go b/internal/app/events.go index 30b3d804..3eb9abb8 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -2,6 +2,28 @@ package app import kit "github.com/mark3labs/kit/pkg/kit" +// Event is the sealed union of all events the app layer emits toward a +// display (the Bubble Tea TUI, the non-interactive CLI handler, or any future +// transport). Every event type defined in this file implements Event via an +// unexported marker method, so the set is closed: only app-owned types can +// satisfy it, and switch statements over Event can be checked for exhaustiveness. +// +// Keeping the fan-out typed as Event (rather than Bubble Tea's untyped tea.Msg) +// means the app package no longer needs to speak the TUI's message vocabulary to +// describe its own events. The concrete transport (prog.Send, a CLI callback) +// adapts Event at the boundary; the event-producing code stays TUI-agnostic. +// +// Note that some events still carry a response channel (PasswordPromptEvent, +// PromptRequestEvent, OverlayRequestEvent, NewSessionRequestEvent). Those are +// not yet serialisable across a process boundary; converting them to a +// request-id correlation scheme is deferred until an actual out-of-process +// transport exists. +type Event interface { + // isAppEvent is an unexported marker that seals the Event union to types + // declared in this package. + isAppEvent() +} + // StreamChunkEvent is sent by the app layer when a streaming text delta arrives // from the LLM. Each chunk contains an incremental portion of the response. type StreamChunkEvent struct { @@ -373,3 +395,47 @@ type OverlayRequestEvent struct { // ResponseCh receives the user's response. Must have buffer size >= 1. ResponseCh chan<- OverlayResponse } + +// -------------------------------------------------------------------------- +// Event union markers +// +// Each app event type implements the sealed Event interface via this +// unexported marker. Grouping the markers here keeps the event structs above +// focused on their fields while making the closed set easy to audit: adding a +// new event type is a compile error until it is listed here. +// -------------------------------------------------------------------------- + +func (StreamChunkEvent) isAppEvent() {} +func (ReasoningChunkEvent) isAppEvent() {} +func (ReasoningCompleteEvent) isAppEvent() {} +func (ToolCallStartedEvent) isAppEvent() {} +func (ToolCallInputStartEvent) isAppEvent() {} +func (ToolCallInputDeltaEvent) isAppEvent() {} +func (ToolCallInputEndEvent) isAppEvent() {} +func (ToolExecutionEvent) isAppEvent() {} +func (ToolResultEvent) isAppEvent() {} +func (ToolOutputEvent) isAppEvent() {} +func (ToolCallContentEvent) isAppEvent() {} +func (PasswordPromptEvent) isAppEvent() {} +func (ResponseCompleteEvent) isAppEvent() {} +func (StepCompleteEvent) isAppEvent() {} +func (StepErrorEvent) isAppEvent() {} +func (StepCancelledEvent) isAppEvent() {} +func (QueueUpdatedEvent) isAppEvent() {} +func (SpinnerEvent) isAppEvent() {} +func (MessageCreatedEvent) isAppEvent() {} +func (CompactCompleteEvent) isAppEvent() {} +func (CompactErrorEvent) isAppEvent() {} +func (SteerConsumedEvent) isAppEvent() {} +func (ModelChangedEvent) isAppEvent() {} +func (UsageUpdatedEvent) isAppEvent() {} +func (WidgetUpdateEvent) isAppEvent() {} +func (ThemeChangedEvent) isAppEvent() {} +func (ContentReloadEvent) isAppEvent() {} +func (MCPToolsReadyEvent) isAppEvent() {} +func (MCPServerLoadedEvent) isAppEvent() {} +func (EditorTextSetEvent) isAppEvent() {} +func (NewSessionRequestEvent) isAppEvent() {} +func (ExtensionPrintEvent) isAppEvent() {} +func (PromptRequestEvent) isAppEvent() {} +func (OverlayRequestEvent) isAppEvent() {} diff --git a/internal/ui/event_handler.go b/internal/ui/event_handler.go index 37ef95e3..63b7a66a 100644 --- a/internal/ui/event_handler.go +++ b/internal/ui/event_handler.go @@ -4,8 +4,6 @@ import ( "fmt" "strings" - tea "charm.land/bubbletea/v2" - "github.com/mark3labs/kit/internal/app" ) @@ -74,7 +72,7 @@ func (h *CLIEventHandler) endStream() { // Handle processes a single app event and renders it via the CLI. This is // the callback passed to app.RunOnceWithDisplay. -func (h *CLIEventHandler) Handle(msg tea.Msg) { +func (h *CLIEventHandler) Handle(msg app.Event) { switch e := msg.(type) { case app.SpinnerEvent: if e.Show { diff --git a/internal/ui/model.go b/internal/ui/model.go index d315e9a4..dbdbcbba 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -108,11 +108,13 @@ type AppController interface { // SwitchTreeSession replaces the active tree session with a new one, // closing the old session. Used by /new to create a completely fresh session. SwitchTreeSession(ts *session.TreeManager) - // SendEvent sends a tea.Msg to the program asynchronously. Safe to call - // from any goroutine. Used by extension command goroutines to deliver - // results back to the TUI without going through tea.Cmd (which can stall - // when the goroutine blocks on interactive prompts). - SendEvent(tea.Msg) + // SendUIMessage re-injects a UI-internal message into the program's Update + // loop asynchronously. Safe to call from any goroutine. Used by extension + // command goroutines (and other async UI work) to deliver results back to + // the TUI without going through tea.Cmd (which can stall when the goroutine + // blocks on interactive prompts). The message is opaque to the app layer; + // it is not an app Event and is only understood by the UI's own Update. + SendUIMessage(tea.Msg) // AddContextMessage adds a user-role message to the conversation history // without triggering an LLM response. Used by the ! shell command prefix // to inject command output into context so the LLM can reference it in @@ -1542,7 +1544,7 @@ func (m *AppModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { forkUserText := msg.UserText go func() { cancelled, reason := emit(forkTargetID, forkIsUser, forkUserText) - ctrl.SendEvent(beforeForkResultMsg{ + ctrl.SendUIMessage(beforeForkResultMsg{ cancelled: cancelled, reason: reason, targetID: forkTargetID, @@ -3938,7 +3940,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd { ctrl := m.appCtrl go func() { output, err := cmdExec(cmdArgs) - ctrl.SendEvent(extensionCmdResultMsg{name: cmdName, output: output, err: err}) + ctrl.SendUIMessage(extensionCmdResultMsg{name: cmdName, output: output, err: err}) }() // Return a non-nil Cmd so the caller knows the command was handled // and doesn't fall through to the regular prompt path. The Cmd itself @@ -4010,7 +4012,7 @@ func (m *AppModel) handleMCPPromptCommand(text string) tea.Cmd { go func() { result, err := expand(serverName, promptName, args) if err != nil { - ctrl.SendEvent(mcpPromptResultMsg{err: err}) + ctrl.SendUIMessage(mcpPromptResultMsg{err: err}) return } // Concatenate user-role messages as the prompt text and collect @@ -4025,7 +4027,7 @@ func (m *AppModel) handleMCPPromptCommand(text string) tea.Cmd { allFileParts = append(allFileParts, msg.FileParts...) } } - ctrl.SendEvent(mcpPromptResultMsg{ + ctrl.SendUIMessage(mcpPromptResultMsg{ text: strings.Join(parts, "\n\n"), fileParts: allFileParts, }) @@ -5165,7 +5167,7 @@ func (m *AppModel) handleNewCommand(initialPrompt string) tea.Cmd { ctrl := m.appCtrl go func() { cancelled, reason := emit("new", initialPrompt) - ctrl.SendEvent(beforeSessionSwitchResultMsg{ + ctrl.SendUIMessage(beforeSessionSwitchResultMsg{ cancelled: cancelled, reason: reason, initialPrompt: initialPrompt, diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 92d0024a..fce3eac9 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -65,7 +65,7 @@ func (s *stubAppController) SwitchTreeSession(_ *session.TreeManager) { // no-op in tests } -func (s *stubAppController) SendEvent(_ tea.Msg) { +func (s *stubAppController) SendUIMessage(_ tea.Msg) { // no-op in tests } From 8bb6cffce4aa4e064bdc34aa33bf21828695d667 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 30 Jul 2026 15:04:56 +0300 Subject: [PATCH 2/2] docs(app): address CodeRabbit review on Event union docs (#101) - events.go: drop the inaccurate claim that Go compiler-checks type-switch exhaustiveness over the sealed Event interface; state that the unexported marker only closes the union to external implementations and that downstream switches should keep a default case. - model.go / model_test.go: update stale SendEvent references in the extension-command goroutine rationale and the session-switch test comment to the renamed SendUIMessage. Refs #101 --- internal/app/events.go | 10 ++++++++-- internal/ui/model.go | 2 +- internal/ui/model_test.go | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/app/events.go b/internal/app/events.go index 3eb9abb8..03bdadb0 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -5,8 +5,14 @@ import kit "github.com/mark3labs/kit/pkg/kit" // Event is the sealed union of all events the app layer emits toward a // display (the Bubble Tea TUI, the non-interactive CLI handler, or any future // transport). Every event type defined in this file implements Event via an -// unexported marker method, so the set is closed: only app-owned types can -// satisfy it, and switch statements over Event can be checked for exhaustiveness. +// unexported marker method. Because that marker is unexported, only types +// declared in this package can satisfy Event — the union is closed to external +// implementations. +// +// Note that Go does not check type-switch exhaustiveness for this pattern: +// adding a new Event variant will not produce a compile error at downstream +// switches, so those switches should keep a default case (or the package a +// sum-type linter) to stay safe. // // Keeping the fan-out typed as Event (rather than Bubble Tea's untyped tea.Msg) // means the app package no longer needs to speak the TUI's message vocabulary to diff --git a/internal/ui/model.go b/internal/ui/model.go index dbdbcbba..35c06145 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -3933,7 +3933,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd { // commands may block on interactive prompts (ctx.PromptSelect etc.) which // wait for the TUI to respond via a channel. A blocking tea.Cmd can stall // BubbleTea's internal Cmd scheduler, causing intermittent freezes. - // The goroutine delivers its result via SendEvent (prog.Send) instead. + // The goroutine delivers its result via SendUIMessage (prog.Send) instead. cmdName := ecmd.Name cmdExec := ecmd.Execute cmdArgs := args diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index fce3eac9..a96349c2 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1298,7 +1298,7 @@ func TestNewSessionRequestEvent_cancelledByExtension(t *testing.T) { }) // The before-hook runs in a goroutine, which sends back a // beforeSessionSwitchResultMsg. Pump that synchronously by reading - // the SendEvent call indirectly: SendEvent on stub is a no-op so we + // the SendUIMessage call indirectly: SendUIMessage on stub is a no-op so we // need to dispatch the message ourselves to simulate the round trip. sendMsg(m, beforeSessionSwitchResultMsg{ cancelled: true,