Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 28 additions & 17 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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()

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 3 additions & 4 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"testing"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions internal/app/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@ 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. 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
// 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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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 {
Expand Down Expand Up @@ -373,3 +401,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() {}
4 changes: 1 addition & 3 deletions internal/ui/event_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"fmt"
"strings"

tea "charm.land/bubbletea/v2"

"github.com/mark3labs/kit/internal/app"
)

Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 13 additions & 11 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3931,14 +3933,14 @@ 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
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})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}()
// 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
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down
Loading