fix: script mode streaming display and example script formatting

- Accumulate stream chunks in a buffer and flush through
  DisplayAssistantMessageWithModel at boundaries (tool calls, step
  complete), mirroring the TUI's StreamComponent accumulate-and-flush
  strategy. Text accompanying tool calls now renders identically to
  solo assistant responses.

- Fix example-script.sh: add missing --- frontmatter delimiters and
  convert legacy command/args format to new type+command list format
  so Viper YAML parsing works correctly.

- Fix env-substitution-script.sh: add missing execute permission.
This commit is contained in:
Ed Zynda
2026-02-26 15:30:39 +03:00
parent 89b3adcc64
commit 7244485ce2
3 changed files with 59 additions and 23 deletions
+54 -18
View File
@@ -730,15 +730,21 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
}
// scriptEventHandler routes app events to CLI display methods for script mode.
// It maintains the spinner state needed for proper display. Script mode does
// not support in-place streaming updates (no Bubble Tea); the final response
// is always rendered as a complete block at the end via StepCompleteEvent.
// It mirrors the TUI's StreamComponent accumulate-and-flush strategy
// (see internal/ui/model.go flushStreamContent and stream.go):
// - StreamChunkEvents are accumulated in a buffer (like StreamComponent).
// - Before tool calls the buffer is flushed through DisplayAssistantMessageWithModel
// so that text accompanying tool calls renders identically to solo responses.
// - ToolCallContentEvent is ignored during streaming (text already in the buffer).
// - ResponseCompleteEvent is used only as a non-streaming fallback.
// - StepCompleteEvent flushes any remaining buffered text.
type scriptEventHandler struct {
cli *ui.CLI
modelName string
spinner *ui.Spinner
lastDisplayed string // tracks content shown via ToolCallContentEvent
lastDisplayed string // tracks content shown (non-streaming)
streamBuf strings.Builder // accumulated stream chunks (mirrors StreamComponent)
}
func newScriptEventHandler(cli *ui.CLI, modelName string) *scriptEventHandler {
@@ -766,6 +772,17 @@ func (h *scriptEventHandler) startSpinner() {
h.spinner.Start()
}
// flushStreamBuffer renders any accumulated stream chunks through the CLI
// formatter (mirrors TUI's flushStreamContent at model.go:781-791).
func (h *scriptEventHandler) flushStreamBuffer() {
text := strings.TrimSpace(h.streamBuf.String())
h.streamBuf.Reset()
if text != "" {
_ = h.cli.DisplayAssistantMessageWithModel(text, h.modelName)
h.lastDisplayed = text
}
}
// Handle processes a single app event and renders it via the CLI.
func (h *scriptEventHandler) Handle(msg tea.Msg) {
switch e := msg.(type) {
@@ -776,8 +793,27 @@ func (h *scriptEventHandler) Handle(msg tea.Msg) {
h.stopSpinner()
}
case app.StreamChunkEvent:
// Accumulate chunks in the buffer (like TUI StreamComponent).
// Text is rendered as a formatted message when flushed at boundaries.
h.stopSpinner()
h.streamBuf.WriteString(e.Content)
case app.ToolCallContentEvent:
// In streaming mode this text was already buffered via StreamChunkEvents
// (mirrors TUI behavior at model.go:405-408). Only display when the
// buffer is empty (non-streaming path).
if h.streamBuf.Len() == 0 {
h.stopSpinner()
_ = h.cli.DisplayAssistantMessageWithModel(e.Content, h.modelName)
h.lastDisplayed = e.Content
h.startSpinner()
}
case app.ToolCallStartedEvent:
h.stopSpinner()
// Flush buffered text before tool call output (mirrors TUI flushStreamContent).
h.flushStreamBuffer()
h.cli.DisplayToolCallMessage(e.ToolName, e.ToolArgs)
case app.ToolExecutionEvent:
@@ -793,29 +829,26 @@ func (h *scriptEventHandler) Handle(msg tea.Msg) {
h.cli.DisplayToolMessage(e.ToolName, e.ToolArgs, resultContent, e.IsError)
h.startSpinner()
case app.StreamChunkEvent:
// Script mode has no in-place streaming display; chunks are ignored.
// The final response is rendered as a complete block in StepCompleteEvent.
h.stopSpinner()
case app.ToolCallContentEvent:
// Text content that accompanies tool calls (e.g. "Let me check that...").
h.stopSpinner()
_ = h.cli.DisplayAssistantMessageWithModel(e.Content, h.modelName)
h.lastDisplayed = e.Content
h.startSpinner()
case app.ResponseCompleteEvent:
h.stopSpinner()
// Non-streaming fallback: display the complete response.
// In streaming mode the buffer will be flushed at StepCompleteEvent.
if h.streamBuf.Len() == 0 && e.Content != "" {
_ = h.cli.DisplayAssistantMessageWithModel(e.Content, h.modelName)
h.lastDisplayed = e.Content
}
case app.StepCompleteEvent:
h.stopSpinner()
// Flush any remaining buffered stream content.
h.flushStreamBuffer()
// Non-streaming fallback: render the full response if not already shown.
responseText := ""
if e.Response != nil {
responseText = e.Response.Content.Text()
}
// Display the final response unless already shown via ToolCallContentEvent.
if responseText != "" && responseText != h.lastDisplayed {
_ = h.cli.DisplayAssistantMessageWithModel(responseText, h.modelName)
}
@@ -825,6 +858,9 @@ func (h *scriptEventHandler) Handle(msg tea.Msg) {
h.cli.UpdateUsageFromResponse(e.Response, "")
}
h.cli.DisplayUsageAfterResponse()
// Reset for next step in the agentic loop.
h.lastDisplayed = ""
}
}
View File
+5 -5
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env -S mcphost script
---
# This script uses the container-use MCP server from https://github.com/dagger/container-use
mcpServers:
container-use:
command: cu
args:
- "stdio"
prompt: |
Create 2 variations of a simple hello world app using Flask and FastAPI. each in their own environment. Give me the URL of each app
type: "local"
command: ["container-use", "stdio"]
---
Create 2 variations of a simple hello world app using Flask and FastAPI. each in their own environment. Give me the URL of each app