mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
fix(ui): print user messages, tool calls, and tool results immediately via tea.Println
The bubbletea refactor was accumulating all messages in StreamComponent and only printing Response.Content.Text() on step completion, causing user messages, tool calls, and tool results to be missing from output. Now only agent streaming text stays live in StreamComponent. Everything else is printed immediately to scrollback: - submitMsg: prints rendered user message - ToolCallStartedEvent: flushes stream text, prints tool call - ToolResultEvent: prints tool result, restarts spinner - HookBlockedEvent: prints blocked notice - ResponseCompleteEvent: prints assistant text (non-streaming mode) - StepCompleteEvent: flushes remaining stream text
This commit is contained in:
@@ -379,125 +379,66 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TestStreamComponent_ToolCallStarted_AppendsToolLine verifies that
|
||||
// ToolCallStartedEvent appends a non-empty tool line.
|
||||
// TestStreamComponent_ToolExecution_IsStarting shows spinner during execution.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestStreamComponent_ToolCallStarted_AppendsToolLine(t *testing.T) {
|
||||
func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.ToolCallStartedEvent{
|
||||
ToolName: "my_tool",
|
||||
ToolArgs: `{"key": "value"}`,
|
||||
})
|
||||
|
||||
if len(c.toolLines) != 1 {
|
||||
t.Fatalf("expected 1 tool line after ToolCallStartedEvent, got %d", len(c.toolLines))
|
||||
}
|
||||
if c.toolLines[0] == "" {
|
||||
t.Fatal("expected non-empty tool line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ToolCallStarted_SetsActiveToolName verifies that
|
||||
// ToolCallStartedEvent sets activeToolName.
|
||||
func TestStreamComponent_ToolCallStarted_SetsActiveToolName(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.ToolCallStartedEvent{
|
||||
ToolName: "active_tool",
|
||||
ToolArgs: "{}",
|
||||
})
|
||||
|
||||
if c.activeToolName != "active_tool" {
|
||||
t.Fatalf("expected activeToolName='active_tool', got %q", c.activeToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TestStreamComponent_ToolResult_AppendsResultLine verifies that
|
||||
// ToolResultEvent appends a result line.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestStreamComponent_ToolResult_AppendsResultLine(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.ToolResultEvent{
|
||||
ToolName: "my_tool",
|
||||
ToolArgs: "{}",
|
||||
Result: "tool output",
|
||||
IsError: false,
|
||||
})
|
||||
|
||||
if len(c.toolLines) != 1 {
|
||||
t.Fatalf("expected 1 tool result line, got %d", len(c.toolLines))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ToolExecution_IsStarting_SetsActiveName verifies
|
||||
// ToolExecutionEvent{IsStarting:true} updates the active tool name.
|
||||
func TestStreamComponent_ToolExecution_IsStarting_SetsActiveName(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
_, cmd := c.Update(app.ToolExecutionEvent{
|
||||
ToolName: "exec_tool",
|
||||
IsStarting: true,
|
||||
})
|
||||
|
||||
if c.activeToolName != "exec_tool" {
|
||||
t.Fatalf("expected activeToolName='exec_tool', got %q", c.activeToolName)
|
||||
if c.phase != streamPhaseSpinner {
|
||||
t.Fatalf("expected streamPhaseSpinner during tool execution, got %v", c.phase)
|
||||
}
|
||||
if !strings.Contains(c.spinnerMsg, "exec_tool") {
|
||||
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ToolExecution_NotStarting_ClearsActiveName verifies
|
||||
// ToolExecutionEvent{IsStarting:false} clears the active tool name.
|
||||
func TestStreamComponent_ToolExecution_NotStarting_ClearsActiveName(t *testing.T) {
|
||||
// TestStreamComponent_ToolExecution_NotStarting goes idle after execution.
|
||||
func TestStreamComponent_ToolExecution_NotStarting_GoesIdle(t *testing.T) {
|
||||
c := newTestStream()
|
||||
c.activeToolName = "some_tool"
|
||||
c.phase = streamPhaseSpinner // simulating execution in progress
|
||||
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
ToolName: "some_tool",
|
||||
IsStarting: false,
|
||||
})
|
||||
|
||||
if c.activeToolName != "" {
|
||||
t.Fatalf("expected activeToolName cleared, got %q", c.activeToolName)
|
||||
if c.phase != streamPhaseIdle {
|
||||
t.Fatalf("expected streamPhaseIdle after tool execution finished, got %v", c.phase)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TestStreamComponent_ToolCallContent_AccumulatesText verifies that
|
||||
// ToolCallContentEvent appends to streamContent.
|
||||
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
|
||||
// text when content is accumulated, and empty string when not.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestStreamComponent_ToolCallContent_AccumulatesText(t *testing.T) {
|
||||
func TestStreamComponent_GetRenderedContent_Empty(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.ToolCallContentEvent{Content: "assistant note"})
|
||||
|
||||
if !strings.Contains(c.streamContent.String(), "assistant note") {
|
||||
t.Fatalf("expected streamContent to contain 'assistant note', got %q", c.streamContent.String())
|
||||
}
|
||||
if c.phase != streamPhaseStreaming {
|
||||
t.Fatalf("expected streamPhaseStreaming after ToolCallContentEvent, got %v", c.phase)
|
||||
if got := c.GetRenderedContent(); got != "" {
|
||||
t.Fatalf("expected empty GetRenderedContent on idle component, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TestStreamComponent_HookBlocked_AppendsLine verifies that HookBlockedEvent
|
||||
// appends a non-empty line to toolLines.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestStreamComponent_HookBlocked_AppendsLine(t *testing.T) {
|
||||
func TestStreamComponent_GetRenderedContent_WithText(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
c = sendStreamMsg(c, app.HookBlockedEvent{Message: "access denied"})
|
||||
|
||||
if len(c.toolLines) != 1 {
|
||||
t.Fatalf("expected 1 tool line after HookBlockedEvent, got %d", len(c.toolLines))
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello world"})
|
||||
got := c.GetRenderedContent()
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty GetRenderedContent after chunks")
|
||||
}
|
||||
if c.toolLines[0] == "" {
|
||||
t.Fatal("expected non-empty hook blocked line")
|
||||
// The rendered output contains ANSI escape codes from the message renderer,
|
||||
// so check for the text fragments rather than an exact substring.
|
||||
if !strings.Contains(got, "hello") {
|
||||
t.Fatalf("expected rendered content to contain 'hello', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +452,6 @@ func TestStreamComponent_Reset(t *testing.T) {
|
||||
// Accumulate some state.
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "some text"})
|
||||
c = sendStreamMsg(c, app.ToolCallStartedEvent{ToolName: "tool", ToolArgs: "{}"})
|
||||
c.spinnerFrame = 5
|
||||
|
||||
c.Reset()
|
||||
@@ -525,15 +465,12 @@ func TestStreamComponent_Reset(t *testing.T) {
|
||||
if c.streamContent.String() != "" {
|
||||
t.Fatalf("expected empty streamContent after Reset(), got %q", c.streamContent.String())
|
||||
}
|
||||
if len(c.toolLines) != 0 {
|
||||
t.Fatalf("expected no toolLines after Reset(), got %d", len(c.toolLines))
|
||||
}
|
||||
if c.activeToolName != "" {
|
||||
t.Fatalf("expected empty activeToolName after Reset(), got %q", c.activeToolName)
|
||||
}
|
||||
if !c.timestamp.IsZero() {
|
||||
t.Fatal("expected zero timestamp after Reset()")
|
||||
}
|
||||
if c.spinnerMsg != "Thinking…" {
|
||||
t.Fatalf("expected spinnerMsg reset to default, got %q", c.spinnerMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
+106
-37
@@ -140,6 +140,9 @@ type streamComponentIface interface {
|
||||
Reset()
|
||||
// SetHeight constrains the render output to at most h lines (0 = unconstrained).
|
||||
SetHeight(h int)
|
||||
// GetRenderedContent returns the rendered assistant message from accumulated
|
||||
// streaming text, or empty string if nothing has been accumulated.
|
||||
GetRenderedContent() string
|
||||
}
|
||||
|
||||
// approvalComponentIface is the interface the parent requires from ApprovalComponent.
|
||||
@@ -280,6 +283,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if cmd := GetCommandByName(msg.Text); cmd != nil && cmd.Name == "/quit" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Print user message immediately to scrollback.
|
||||
cmds = append(cmds, m.printUserMessage(msg.Text))
|
||||
if m.appCtrl != nil {
|
||||
// app.Run() handles queueing internally if a step is in progress.
|
||||
m.appCtrl.Run(msg.Text)
|
||||
@@ -320,40 +325,45 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case app.ToolCallStartedEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Flush any accumulated streaming text to scrollback first (streaming
|
||||
// always completes before tool calls fire), then print the tool call.
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
cmds = append(cmds, m.printToolCall(msg))
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
// Pass to stream component for execution spinner display.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolResultEvent:
|
||||
// Print tool result immediately to scrollback.
|
||||
cmds = append(cmds, m.printToolResult(msg))
|
||||
// Start spinner again while waiting for the next LLM response.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolCallContentEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// In streaming mode this text was already delivered via StreamChunkEvents
|
||||
// and will be flushed before the next tool call. Ignore to avoid
|
||||
// double-printing.
|
||||
|
||||
case app.ResponseCompleteEvent:
|
||||
// Non-streaming mode: this carries the full response text (StreamChunkEvents
|
||||
// never fire). Print it immediately.
|
||||
if msg.Content != "" {
|
||||
cmds = append(cmds, m.printAssistantMessage(msg.Content))
|
||||
}
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
m.stream.Reset() // stop spinner
|
||||
}
|
||||
|
||||
case app.HookBlockedEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Print hook blocked message immediately to scrollback.
|
||||
cmds = append(cmds, m.printHookBlocked(msg))
|
||||
|
||||
case app.MessageCreatedEvent:
|
||||
// Informational — no action needed by parent.
|
||||
@@ -371,9 +381,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.approval = approvalComp
|
||||
|
||||
case app.StepCompleteEvent:
|
||||
// Emit the completed response above the BT region via tea.Println,
|
||||
// then reset the stream component and return to input state.
|
||||
cmds = append(cmds, m.printCompletedResponse(msg))
|
||||
// Flush any remaining streamed text to scrollback, then reset stream
|
||||
// and return to input state.
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
}
|
||||
@@ -381,7 +391,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.canceling = false
|
||||
|
||||
case app.StepErrorEvent:
|
||||
// Render the error above the BT region via tea.Println, reset stream, return to input.
|
||||
// Flush streamed text, print the error, reset stream, return to input.
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
if msg.Err != nil {
|
||||
cmds = append(cmds, m.printErrorResponse(msg))
|
||||
}
|
||||
@@ -489,37 +500,80 @@ func (m *AppModel) renderInput() string {
|
||||
return m.input.View().Content
|
||||
}
|
||||
|
||||
// printCompletedResponse builds a tea.Cmd that emits the final response text
|
||||
// above the BT-managed region using tea.Println. This is used on StepCompleteEvent.
|
||||
func (m *AppModel) printCompletedResponse(evt app.StepCompleteEvent) tea.Cmd {
|
||||
if evt.Response == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := evt.Response.Content.Text()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
// --------------------------------------------------------------------------
|
||||
// Print helpers — emit content to scrollback via tea.Println
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// printUserMessage renders a user message and emits it above the BT region.
|
||||
func (m *AppModel) printUserMessage(text string) tea.Cmd {
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
msg := m.compactRdr.RenderUserMessage(text, time.Now())
|
||||
rendered = msg.Content
|
||||
} else {
|
||||
msg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
msg := m.renderer.RenderUserMessage(text, time.Now())
|
||||
rendered = msg.Content
|
||||
}
|
||||
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// printErrorResponse builds a tea.Cmd that emits a styled error message above
|
||||
// the BT-managed region using tea.Println. This is used on StepErrorEvent.
|
||||
// printAssistantMessage renders an assistant message and emits it above the BT region.
|
||||
func (m *AppModel) printAssistantMessage(text string) tea.Cmd {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderAssistantMessage(text, time.Now(), m.modelName)
|
||||
rendered = msg.Content
|
||||
} else {
|
||||
msg := m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName)
|
||||
rendered = msg.Content
|
||||
}
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// printToolCall renders a tool call message and emits it above the BT region.
|
||||
func (m *AppModel) printToolCall(evt app.ToolCallStartedEvent) tea.Cmd {
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderToolCallMessage(evt.ToolName, evt.ToolArgs, time.Now())
|
||||
rendered = msg.Content
|
||||
} else {
|
||||
msg := m.renderer.RenderToolCallMessage(evt.ToolName, evt.ToolArgs, time.Now())
|
||||
rendered = msg.Content
|
||||
}
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// printToolResult renders a tool result message and emits it above the BT region.
|
||||
func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd {
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError)
|
||||
rendered = msg.Content
|
||||
} else {
|
||||
msg := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError)
|
||||
rendered = msg.Content
|
||||
}
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// printHookBlocked renders a hook-blocked notice and emits it above the BT region.
|
||||
func (m *AppModel) printHookBlocked(evt app.HookBlockedEvent) tea.Cmd {
|
||||
theme := GetTheme()
|
||||
rendered := lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Bold(true).
|
||||
Render(" ⛔ Hook blocked: " + evt.Message)
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// printErrorResponse renders an error message and emits it above the BT region.
|
||||
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
|
||||
if evt.Err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderErrorMessage(evt.Err.Error(), time.Now())
|
||||
@@ -528,10 +582,25 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
|
||||
msg := m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now())
|
||||
rendered = msg.Content
|
||||
}
|
||||
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// flushStreamContent gets the rendered content from the stream component,
|
||||
// emits it above the BT region via tea.Println, and resets the stream. This
|
||||
// is called before printing tool calls (streaming completes before tools fire)
|
||||
// and on step completion.
|
||||
func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
if m.stream == nil {
|
||||
return nil
|
||||
}
|
||||
content := m.stream.GetRenderedContent()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
m.stream.Reset()
|
||||
return tea.Println(content)
|
||||
}
|
||||
|
||||
// distributeHeight recalculates child component heights after a window resize
|
||||
// and propagates the computed stream height to the StreamComponent.
|
||||
//
|
||||
|
||||
+76
-17
@@ -50,9 +50,10 @@ func (s *stubAppController) ClearMessages() {
|
||||
|
||||
// stubStreamComponent satisfies streamComponentIface without rendering anything.
|
||||
type stubStreamComponent struct {
|
||||
resetCalled int
|
||||
height int
|
||||
lastMsg tea.Msg
|
||||
resetCalled int
|
||||
height int
|
||||
lastMsg tea.Msg
|
||||
renderedContent string // returned by GetRenderedContent
|
||||
}
|
||||
|
||||
func (s *stubStreamComponent) Init() tea.Cmd { return nil }
|
||||
@@ -60,9 +61,10 @@ func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.lastMsg = msg
|
||||
return s, nil
|
||||
}
|
||||
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
|
||||
func (s *stubStreamComponent) Reset() { s.resetCalled++ }
|
||||
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
|
||||
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
|
||||
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
|
||||
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
|
||||
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
|
||||
|
||||
// stubInputComponent satisfies inputComponentIface without rendering anything.
|
||||
type stubInputComponent struct {
|
||||
@@ -429,28 +431,29 @@ func TestWindowResize_distributeHeight(t *testing.T) {
|
||||
// tea.Println on step complete
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestStepComplete_printCmd verifies that StepCompleteEvent produces a non-nil
|
||||
// tea.Cmd (the tea.Println call). We verify the Cmd is non-nil rather than
|
||||
// executing it, since tea.Println is a runtime command.
|
||||
func TestStepComplete_printCmd(t *testing.T) {
|
||||
// TestStepComplete_flushesStreamContent verifies that StepCompleteEvent
|
||||
// flushes accumulated stream content via tea.Println (non-nil cmd).
|
||||
func TestStepComplete_flushesStreamContent(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m, stream, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
// Simulate accumulated streaming text.
|
||||
stream.renderedContent = "rendered assistant text"
|
||||
|
||||
_, cmd := m.Update(app.StepCompleteEvent{
|
||||
Response: makeTestResponse("final answer"),
|
||||
Usage: fantasy.Usage{},
|
||||
})
|
||||
|
||||
// A non-nil cmd means printCompletedResponse returned tea.Println(...)
|
||||
// A non-nil cmd means flushStreamContent returned tea.Println(...)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) on StepCompleteEvent with response")
|
||||
t.Fatal("expected non-nil cmd (tea.Println) on StepCompleteEvent with stream content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStepComplete_nilResponse_noCmd verifies that StepCompleteEvent with a nil
|
||||
// response produces a nil cmd (nothing to print).
|
||||
func TestStepComplete_nilResponse_noCmd(t *testing.T) {
|
||||
// TestStepComplete_noStreamContent_noCmd verifies that StepCompleteEvent with
|
||||
// no accumulated stream content produces a nil cmd (nothing to flush).
|
||||
func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
@@ -458,7 +461,63 @@ func TestStepComplete_nilResponse_noCmd(t *testing.T) {
|
||||
_, cmd := m.Update(app.StepCompleteEvent{Response: nil})
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd on StepCompleteEvent with nil response")
|
||||
t.Fatal("expected nil cmd on StepCompleteEvent with no stream content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
|
||||
// cmd for the user message.
|
||||
func TestSubmitMsg_printsUserMessage(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
|
||||
_, cmd := m.Update(submitMsg{Text: "user query"})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallStarted_flushesAndPrints verifies that ToolCallStartedEvent
|
||||
// produces a non-nil cmd (flush + tool call print).
|
||||
func TestToolCallStarted_flushesAndPrints(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
_, cmd := m.Update(app.ToolCallStartedEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: `{"cmd":"ls"}`,
|
||||
})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd on ToolCallStartedEvent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
|
||||
// a non-nil cmd and the stream receives a SpinnerEvent.
|
||||
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, stream, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
_, cmd := m.Update(app.ToolResultEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: "{}",
|
||||
Result: "output",
|
||||
IsError: false,
|
||||
})
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd on ToolResultEvent")
|
||||
}
|
||||
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
|
||||
if stream.lastMsg == nil {
|
||||
t.Fatal("expected stream to receive SpinnerEvent after ToolResultEvent")
|
||||
}
|
||||
if se, ok := stream.lastMsg.(app.SpinnerEvent); !ok || !se.Show {
|
||||
t.Fatalf("expected SpinnerEvent{Show:true}, got %T", stream.lastMsg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-103
@@ -83,24 +83,22 @@ const (
|
||||
|
||||
// StreamComponent is the Bubble Tea child model responsible for the stream
|
||||
// region: it renders a KITT-style spinner when the agent is thinking, and
|
||||
// switches to live text once StreamChunkEvents start arriving. It also renders
|
||||
// intermediate tool-call events.
|
||||
// switches to live text once StreamChunkEvents start arriving.
|
||||
//
|
||||
// Tool calls, tool results, user messages, and other non-streaming content
|
||||
// are printed immediately by the parent AppModel via tea.Println(). The
|
||||
// StreamComponent only handles the live streaming text and spinner display.
|
||||
//
|
||||
// Lifecycle is managed entirely by the parent AppModel:
|
||||
// - Parent calls Reset() between agent steps to clear state.
|
||||
// - Parent emits completed responses above the BT region via tea.Println()
|
||||
// (see AppModel.printCompletedResponse); StreamComponent never calls tea.Quit.
|
||||
// then calls Reset(); StreamComponent never calls tea.Quit.
|
||||
//
|
||||
// Events handled:
|
||||
// - app.SpinnerEvent{Show:true} → enter spinner phase, start tick loop
|
||||
// - app.SpinnerEvent{Show:false} → (unused — first chunk transitions automatically)
|
||||
// - app.StreamChunkEvent → append text, enter streaming phase
|
||||
// - app.ToolCallStartedEvent → record active tool call
|
||||
// - app.ToolExecutionEvent → update tool execution status
|
||||
// - app.ToolResultEvent → append rendered tool result line
|
||||
// - app.ToolCallContentEvent → append assistant commentary text
|
||||
// - app.ResponseCompleteEvent → no-op (parent handles completion)
|
||||
// - app.HookBlockedEvent → append block message line
|
||||
// - app.ToolExecutionEvent → show execution spinner during tool run
|
||||
type StreamComponent struct {
|
||||
// phase tracks what we are currently showing.
|
||||
phase streamPhase
|
||||
@@ -117,17 +115,10 @@ type StreamComponent struct {
|
||||
// streamContent accumulates all streaming text chunks.
|
||||
streamContent strings.Builder
|
||||
|
||||
// toolLines are rendered-and-finalized tool event lines appended above the
|
||||
// live streaming text.
|
||||
toolLines []string
|
||||
|
||||
// activeToolName tracks the tool currently being executed (for status updates).
|
||||
activeToolName string
|
||||
|
||||
// messageRenderer renders tool / content lines in standard mode.
|
||||
// messageRenderer renders assistant messages in standard mode.
|
||||
messageRenderer *MessageRenderer
|
||||
|
||||
// compactRenderer renders tool / content lines in compact mode.
|
||||
// compactRenderer renders assistant messages in compact mode.
|
||||
compactRenderer *CompactRenderer
|
||||
|
||||
// compactMode selects which renderer to use.
|
||||
@@ -178,12 +169,22 @@ func (s *StreamComponent) SetHeight(h int) {
|
||||
func (s *StreamComponent) Reset() {
|
||||
s.phase = streamPhaseIdle
|
||||
s.spinnerFrame = 0
|
||||
s.spinnerMsg = "Thinking…"
|
||||
s.streamContent.Reset()
|
||||
s.toolLines = nil
|
||||
s.activeToolName = ""
|
||||
s.timestamp = time.Time{}
|
||||
}
|
||||
|
||||
// GetRenderedContent returns the rendered assistant message from the accumulated
|
||||
// streaming text. Returns empty string if no text has been accumulated. Used by
|
||||
// the parent AppModel to flush content via tea.Println() before resetting.
|
||||
func (s *StreamComponent) GetRenderedContent() string {
|
||||
text := s.streamContent.String()
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return s.renderStreamingText(text)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// tea.Model interface
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -231,40 +232,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
s.streamContent.WriteString(msg.Content)
|
||||
|
||||
case app.ToolCallStartedEvent:
|
||||
s.activeToolName = msg.ToolName
|
||||
// Render a "starting" tool call line and append it.
|
||||
line := s.renderToolCallLine(msg.ToolName, msg.ToolArgs, time.Now())
|
||||
s.toolLines = append(s.toolLines, line)
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
if msg.IsStarting {
|
||||
s.activeToolName = msg.ToolName
|
||||
} else {
|
||||
s.activeToolName = ""
|
||||
// Show a KITT spinner with the tool name while the tool executes.
|
||||
s.phase = streamPhaseSpinner
|
||||
s.spinnerMsg = "Executing " + msg.ToolName + "…"
|
||||
s.spinnerFrame = 0
|
||||
return s, streamSpinnerTickCmd()
|
||||
}
|
||||
|
||||
case app.ToolResultEvent:
|
||||
line := s.renderToolResultLine(msg.ToolName, msg.ToolArgs, msg.Result, msg.IsError)
|
||||
s.toolLines = append(s.toolLines, line)
|
||||
|
||||
case app.ToolCallContentEvent:
|
||||
// Assistant commentary that accompanies a tool call — treat as streamed text.
|
||||
if s.phase != streamPhaseStreaming {
|
||||
s.phase = streamPhaseStreaming
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
}
|
||||
s.streamContent.WriteString(msg.Content)
|
||||
|
||||
case app.ResponseCompleteEvent:
|
||||
// No-op: parent handles completion via StepCompleteEvent.
|
||||
|
||||
case app.HookBlockedEvent:
|
||||
// Append a styled notice line.
|
||||
line := s.renderHookBlockedLine(msg.Message)
|
||||
s.toolLines = append(s.toolLines, line)
|
||||
// Tool finished — go idle. Parent will trigger a new spinner for
|
||||
// the next LLM call if needed.
|
||||
s.phase = streamPhaseIdle
|
||||
}
|
||||
|
||||
return s, nil
|
||||
@@ -290,25 +268,13 @@ func (s *StreamComponent) render() string {
|
||||
content = s.renderSpinner()
|
||||
|
||||
case streamPhaseStreaming:
|
||||
var parts []string
|
||||
|
||||
// Tool event lines rendered above the live text.
|
||||
parts = append(parts, s.toolLines...)
|
||||
|
||||
// Live streaming assistant text.
|
||||
// Live streaming assistant text only. Tool calls, results, and
|
||||
// other messages are printed immediately by the parent via tea.Println.
|
||||
text := s.streamContent.String()
|
||||
if text != "" {
|
||||
parts = append(parts, s.renderStreamingText(text))
|
||||
content = s.renderStreamingText(text)
|
||||
}
|
||||
|
||||
// Show active tool status if a tool is still running.
|
||||
if s.activeToolName != "" {
|
||||
activeLine := s.renderActiveToolLine(s.activeToolName)
|
||||
parts = append(parts, activeLine)
|
||||
}
|
||||
|
||||
content = strings.Join(parts, "\n")
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -353,40 +319,3 @@ func (s *StreamComponent) renderStreamingText(text string) string {
|
||||
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
|
||||
return msg.Content
|
||||
}
|
||||
|
||||
// renderToolCallLine renders a single "tool being called" line.
|
||||
func (s *StreamComponent) renderToolCallLine(toolName, toolArgs string, ts time.Time) string {
|
||||
if s.compactMode {
|
||||
msg := s.compactRenderer.RenderToolCallMessage(toolName, toolArgs, ts)
|
||||
return msg.Content
|
||||
}
|
||||
msg := s.messageRenderer.RenderToolCallMessage(toolName, toolArgs, ts)
|
||||
return msg.Content
|
||||
}
|
||||
|
||||
// renderToolResultLine renders a single "tool result" line.
|
||||
func (s *StreamComponent) renderToolResultLine(toolName, toolArgs, result string, isError bool) string {
|
||||
if s.compactMode {
|
||||
msg := s.compactRenderer.RenderToolMessage(toolName, toolArgs, result, isError)
|
||||
return msg.Content
|
||||
}
|
||||
msg := s.messageRenderer.RenderToolMessage(toolName, toolArgs, result, isError)
|
||||
return msg.Content
|
||||
}
|
||||
|
||||
// renderActiveToolLine renders a small inline spinner for a tool still executing.
|
||||
func (s *StreamComponent) renderActiveToolLine(toolName string) string {
|
||||
theme := GetTheme()
|
||||
dot := lipgloss.NewStyle().Foreground(theme.Tool).Render("⠋")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Tool).Italic(true).Render(toolName + "…")
|
||||
return " " + dot + " " + label
|
||||
}
|
||||
|
||||
// renderHookBlockedLine renders a notice that a hook blocked an action.
|
||||
func (s *StreamComponent) renderHookBlockedLine(message string) string {
|
||||
theme := GetTheme()
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Bold(true).
|
||||
Render(" ⛔ Hook blocked: " + message)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user