From 4b5d85fb77f23f119895d483a0068a1cf1c1e909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Thu, 21 May 2026 09:39:59 +0200 Subject: [PATCH] Add live-streaming tool boxes with per-call state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool execution is now rendered as a Unicode box that opens on tool_execution_start and streams stdout line-by-line as pi emits tool_execution_update snapshots. The box closes on end with a green āœ“ / red āœ— status, elapsed time, and line count. Parallel tool calls (model issues multiple bash calls in one turn) are handled via a per-toolCallId state map and a FIFO queue: only one box streams live at a time; others accumulate output silently and flush in order when their predecessor closes. - event: add ToolCallID field (toolCallId from pi's RPC events) - render: replace single-box state with tools map + activeID/queue - ToolExecStart(id, name, args) opens or queues a box - ToolExecUpdate(id, snapshot) delta-streams complete lines - ToolExecEnd(id, isErr, fullText) closes box, promotes next - full output — no truncation - cli: wire tool_execution_update; drop toolcall_end render (execution box makes it redundant) - ansi: swap BoldBlue/BoldYellow for BoldCyan/DimCyan/BoldGreen/BoldRed --- README.md | 20 ++- internal/cli/cli.go | 15 +- internal/cli/cli_test.go | 70 ++++++--- internal/event/event.go | 10 +- internal/render/ansi.go | 12 +- internal/render/render.go | 202 ++++++++++++++++++++----- internal/render/render_test.go | 265 ++++++++++++++++++++++++++------- 7 files changed, 461 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 62c0573..a7b0a44 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,23 @@ from pi's stdout as styled terminal output: - thinking tokens in dim italic - assistant text plain -- tool calls (`šŸ”§ name args`) when the model decides to invoke a tool -- tool execution (`⚔ name: cmd`) when the tool actually runs -- truncated tool result with `āœ“` / `āœ—` status +- tool execution rendered as a box with live, line-by-line streamed output: + + ``` + ā”Œā”€ ⚔ bash ─ seq 1 5 | while read i; do echo line $i; sleep 0.3; done + │ line 1 + │ line 2 + │ line 3 + │ line 4 + │ line 5 + └─ āœ“ bash (1.6s, 5 lines) + ``` + + The top-corner header opens when the tool starts, each new line of the + tool's stdout streams in with a `│` gutter as soon as `pi` emits it, and + the bottom corner closes with a green `āœ“` (success) or red `āœ—` (error), + the elapsed time, and the line count. Full output is preserved — nothing + is truncated. ## Requirements diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 42e99f2..afea217 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -122,11 +122,11 @@ func handleEvent(r *render.Renderer, env event.Envelope, stderr io.Writer) (bool handleMessage(r, env.AssistantMessageEvent) } case "tool_execution_start": - r.ToolExecStart(env.ToolName, env.Args) + r.ToolExecStart(env.ToolCallID, env.ToolName, env.Args) case "tool_execution_update": - // Streaming output; nothing to render today. + r.ToolExecUpdate(env.ToolCallID, env.PartialResult.SummaryText()) case "tool_execution_end": - r.ToolExecEnd(env.ToolName, env.IsError, env.Result.SummaryText()) + r.ToolExecEnd(env.ToolCallID, env.IsError, env.Result.SummaryText()) case "turn_start": r.TurnStart() case "turn_end": @@ -151,11 +151,8 @@ func handleMessage(r *render.Renderer, msg *event.AssistantMessageEvent) { r.Thinking(msg.Delta) case "text_delta": r.Text(msg.Delta) - case "toolcall_end": - if msg.ToolCall != nil { - r.ToolCall(msg.ToolCall.Name, msg.ToolCall.Arguments) - } - // thinking_start, text_start, toolcall_start, toolcall_delta: - // no-op — renderer handles section transitions on the deltas. + // thinking_start, text_start, toolcall_*: no-op. The tool-execution + // box (rendered from tool_execution_* events) is enough; rendering + // toolcall_end would just duplicate the header. } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index eaebea4..0479ddc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -71,18 +71,63 @@ func TestHandleEventToolExecEndRenders(t *testing.T) { var out bytes.Buffer r := render.New(&out) _, _ = handleEvent(r, event.Envelope{ - Type: "tool_execution_end", - ToolName: "bash", - IsError: false, + Type: "tool_execution_start", + ToolCallID: "call-1", + ToolName: "bash", + Args: map[string]any{"command": "x"}, + }, &bytes.Buffer{}) + _, _ = handleEvent(r, event.Envelope{ + Type: "tool_execution_end", + ToolCallID: "call-1", + ToolName: "bash", + IsError: false, Result: &event.Result{Content: []event.ResultContent{ {Text: "ok"}, }}, }, &bytes.Buffer{}) - if got := out.String(); !strings.Contains(got, "āœ“ bash → ok") { - t.Errorf("expected āœ“ summary, got %q", got) + got := stripANSI(out.String()) + if !strings.Contains(got, "│ ok") { + t.Errorf("expected output line in box, got %q", got) + } + if !strings.Contains(got, "āœ“ bash") { + t.Errorf("expected āœ“ bash footer, got %q", got) } } +func TestHandleEventToolExecUpdateStreamsLines(t *testing.T) { + t.Parallel() + var out bytes.Buffer + r := render.New(&out) + r.ToolExecStart("call-1", "bash", map[string]any{"command": "seq 1 2"}) + _, _ = handleEvent(r, event.Envelope{ + Type: "tool_execution_update", + ToolCallID: "call-1", + ToolName: "bash", + PartialResult: &event.Result{Content: []event.ResultContent{ + {Text: "line 1\n"}, + }}, + }, &bytes.Buffer{}) + got := stripANSI(out.String()) + if !strings.Contains(got, "│ line 1") { + t.Errorf("expected streamed line, got %q", got) + } +} + +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + i += 2 + for i < len(s) && s[i] >= 0x20 && s[i] < 0x40 { + i++ + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + func TestHandleMessageThinkingDelta(t *testing.T) { t.Parallel() var out bytes.Buffer @@ -93,7 +138,7 @@ func TestHandleMessageThinkingDelta(t *testing.T) { } } -func TestHandleMessageToolCallEndRendersName(t *testing.T) { +func TestHandleMessageToolCallEndIsNoOp(t *testing.T) { t.Parallel() var out bytes.Buffer r := render.New(&out) @@ -101,18 +146,7 @@ func TestHandleMessageToolCallEndRendersName(t *testing.T) { Type: "toolcall_end", ToolCall: &event.ToolCall{Name: "bash", Arguments: map[string]any{"command": "ls"}}, }) - got := out.String() - if !strings.Contains(got, "šŸ”§ bash") { - t.Errorf("expected šŸ”§ bash, got %q", got) - } -} - -func TestHandleMessageToolCallEndWithoutToolCallIsNoOp(t *testing.T) { - t.Parallel() - var out bytes.Buffer - r := render.New(&out) - handleMessage(r, &event.AssistantMessageEvent{Type: "toolcall_end", ToolCall: nil}) if out.String() != "" { - t.Errorf("expected no output, got %q", out.String()) + t.Errorf("toolcall_end should not render — box comes from tool_execution_*; got %q", out.String()) } } diff --git a/internal/event/event.go b/internal/event/event.go index 05baec8..52a9c4f 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -18,10 +18,12 @@ type Envelope struct { AssistantMessageEvent *AssistantMessageEvent `json:"assistantMessageEvent,omitempty"` // Populated for type=="tool_execution_*". - ToolName string `json:"toolName,omitempty"` - Args map[string]any `json:"args,omitempty"` - IsError bool `json:"isError,omitempty"` - Result *Result `json:"result,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + Args map[string]any `json:"args,omitempty"` + IsError bool `json:"isError,omitempty"` + Result *Result `json:"result,omitempty"` + PartialResult *Result `json:"partialResult,omitempty"` } // AssistantMessageEvent describes a single token-level event emitted by the diff --git a/internal/render/ansi.go b/internal/render/ansi.go index 14c81dd..7c41189 100644 --- a/internal/render/ansi.go +++ b/internal/render/ansi.go @@ -2,9 +2,11 @@ package render // ANSI escape sequences used to style the streaming output. const ( - ansiReset = "\033[0m" - ansiDim = "\033[2m" - ansiDimItalic = "\033[2;3m" - ansiBoldBlue = "\033[1;34m" - ansiBoldYellow = "\033[1;33m" + ansiReset = "\033[0m" + ansiDim = "\033[2m" + ansiDimItalic = "\033[2;3m" + ansiBoldCyan = "\033[1;36m" + ansiDimCyan = "\033[2;36m" + ansiBoldGreen = "\033[1;32m" + ansiBoldRed = "\033[1;31m" ) diff --git a/internal/render/render.go b/internal/render/render.go index 2d2d0a6..6042fc2 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -12,12 +12,9 @@ import ( "fmt" "io" "strings" + "time" ) -// summaryMaxLen caps the rendered tool-result summary so that a chatty -// command doesn't flood the screen. -const summaryMaxLen = 200 - // State describes which kind of content the renderer most recently emitted. type State int @@ -29,6 +26,24 @@ const ( StateTool // tool call / tool execution lines ) +// toolBox tracks one in-flight tool execution. pi can interleave events for +// multiple tool calls (e.g. the model issues two bash calls in parallel), so +// we keep per-call state and only stream one box live at a time; others are +// queued and rendered as their turn comes up. +type toolBox struct { + id string + name string + args map[string]any + startTime time.Time + snapshot string // latest cumulative stdout from updates / end + bytesEmitted int // bytes of snapshot already turned into output + lineBuf strings.Builder + lineCount int + ended bool + isErr bool + headerEmitted bool +} + // Renderer streams styled events to an io.Writer while tracking enough // state to insert section boundaries cleanly. // @@ -37,12 +52,20 @@ type Renderer struct { out io.Writer state State atLineStart bool + + // activeID is the toolCallId currently streaming its box live; "" if + // no box is open. tools holds every open call (active + queued + ended- + // but-waiting). queue is the FIFO of toolCallIds waiting for the active + // box to close. + activeID string + tools map[string]*toolBox + queue []string } // New returns a Renderer that writes to out. The renderer assumes it starts // at the beginning of a fresh line. func New(out io.Writer) *Renderer { - return &Renderer{out: out, atLineStart: true} + return &Renderer{out: out, atLineStart: true, tools: map[string]*toolBox{}} } // Thinking emits a thinking delta in dim italic. Consecutive deltas stay on @@ -69,45 +92,148 @@ func (r *Renderer) Text(delta string) { r.write(delta) } -// ToolCall renders the "šŸ”§ name args" line emitted when the model finishes -// describing a tool call. -func (r *Renderer) ToolCall(name string, args map[string]any) { - r.ensureNewline() - if len(args) > 0 { - r.printf("%sšŸ”§ %s %s%s\n", ansiBoldBlue, name, marshalJSON(args), ansiReset) +// ToolExecStart registers a new tool call. If no other call is currently +// streaming, its box header is emitted immediately and live updates will +// flow into it. Otherwise the call is queued; its header (and accumulated +// output) is emitted once the active box closes. +func (r *Renderer) ToolExecStart(id, name string, args map[string]any) { + box := &toolBox{ + id: id, + name: name, + args: args, + startTime: time.Now(), + } + r.tools[id] = box + if r.activeID == "" { + r.activeID = id + r.emitHeader(box) } else { - r.printf("%sšŸ”§ %s%s\n", ansiBoldBlue, name, ansiReset) + r.queue = append(r.queue, id) + } +} + +// ToolExecUpdate records a cumulative stdout snapshot for the given call. +// If the call is the active one, newly-completed lines are streamed to the +// terminal right away. Snapshots for queued calls accumulate silently. +func (r *Renderer) ToolExecUpdate(id, snapshot string) { + box, ok := r.tools[id] + if !ok { + return + } + box.snapshot = snapshot + if r.activeID == id { + r.consumeBox(box) + } +} + +// ToolExecEnd marks the call as finished. If it's the active call, the box +// is closed and the next queued call (if any) is promoted into the live +// slot. If the call was queued, it stays queued (now marked ended) and is +// rendered as a static box when its turn arrives. +func (r *Renderer) ToolExecEnd(id string, isErr bool, fullText string) { + box, ok := r.tools[id] + if !ok { + return + } + if fullText != "" { + box.snapshot = fullText + } + box.ended = true + box.isErr = isErr + + if r.activeID == id { + r.consumeBox(box) + r.closeBox(box) + delete(r.tools, id) + r.activeID = "" + r.promoteNext() + } +} + +// promoteNext pulls the next queued call into the live slot, emits its +// header, replays any output it accumulated while queued, and — if it has +// already ended — closes it immediately. Loops so that a chain of already- +// finished calls all flush out at once. +func (r *Renderer) promoteNext() { + for len(r.queue) > 0 { + next := r.queue[0] + r.queue = r.queue[1:] + box, ok := r.tools[next] + if !ok { + continue + } + r.activeID = next + r.emitHeader(box) + r.consumeBox(box) + if box.ended { + r.closeBox(box) + delete(r.tools, next) + r.activeID = "" + continue + } + return } - r.atLineStart = true - r.state = StateTool } -// ToolExecStart renders the "⚔ name: cmd" header for a tool that has begun -// running. For bash-like tools the literal command is rendered; for others -// the full args object is dumped as JSON. -func (r *Renderer) ToolExecStart(name string, args map[string]any) { +func (r *Renderer) emitHeader(b *toolBox) { r.ensureNewline() - if cmd, ok := args["command"].(string); ok { - r.printf("%s⚔ %s: %s%s\n", ansiBoldYellow, name, cmd, ansiReset) + if cmd, ok := b.args["command"].(string); ok { + r.printf("%sā”Œā”€ ⚔ %s ─%s %s\n", ansiBoldCyan, b.name, ansiReset, cmd) + } else if len(b.args) > 0 { + r.printf("%sā”Œā”€ ⚔ %s%s %s\n", ansiBoldCyan, b.name, ansiReset, marshalJSON(b.args)) } else { - r.printf("%s⚔ %s %s%s\n", ansiBoldYellow, name, marshalJSON(args), ansiReset) + r.printf("%sā”Œā”€ ⚔ %s%s\n", ansiBoldCyan, b.name, ansiReset) } + b.headerEmitted = true r.state = StateTool r.atLineStart = true } -// ToolExecEnd renders the truncated summary line for a finished tool -// execution. isErr controls the āœ“/āœ— marker. -func (r *Renderer) ToolExecEnd(name string, isErr bool, summary string) { - r.ensureNewline() - summary = truncate(strings.TrimSpace(summary), summaryMaxLen) - status := "āœ“" - if isErr { - status = "āœ—" +func (r *Renderer) consumeBox(b *toolBox) { + if len(b.snapshot) <= b.bytesEmitted { + return } - r.printf("%s %s %s → %s%s\n", ansiDim, status, name, summary, ansiReset) - r.atLineStart = true + delta := b.snapshot[b.bytesEmitted:] + b.bytesEmitted = len(b.snapshot) + b.lineBuf.WriteString(delta) + s := b.lineBuf.String() + b.lineBuf.Reset() + for { + i := strings.IndexByte(s, '\n') + if i < 0 { + break + } + r.emitToolLine(s[:i]) + b.lineCount++ + s = s[i+1:] + } + b.lineBuf.WriteString(s) +} + +func (r *Renderer) closeBox(b *toolBox) { + if rem := b.lineBuf.String(); rem != "" { + r.emitToolLine(rem) + b.lineCount++ + b.lineBuf.Reset() + } + status, color := "āœ“", ansiBoldGreen + if b.isErr { + status, color = "āœ—", ansiBoldRed + } + info := formatDuration(time.Since(b.startTime)) + if b.lineCount > 0 { + info = fmt.Sprintf("%s, %d lines", info, b.lineCount) + } + r.printf("%s└─%s %s%s %s%s %s(%s)%s\n", + ansiBoldCyan, ansiReset, + color, status, b.name, ansiReset, + ansiDim, info, ansiReset) r.state = StateTool + r.atLineStart = true +} + +func (r *Renderer) emitToolLine(line string) { + r.printf("%s│%s %s\n", ansiDimCyan, ansiReset, line) } // TurnStart inserts a newline if the previous section left the cursor mid-line. @@ -152,13 +278,6 @@ func (r *Renderer) printf(format string, args ...any) { _, _ = fmt.Fprintf(r.out, format, args...) } -func truncate(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} - // marshalJSON renders v as compact JSON without Go's default HTML escaping, // so &, <, > survive intact in tool argument blobs. func marshalJSON(v any) string { @@ -168,3 +287,10 @@ func marshalJSON(v any) string { _ = enc.Encode(v) return strings.TrimRight(buf.String(), "\n") } + +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 3bc92bc..f1f17e8 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -4,8 +4,26 @@ import ( "bytes" "strings" "testing" + "time" ) +// stripANSI removes CSI escape sequences so tests can assert against the +// visible text without having to know the exact color codes. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + i += 2 + for i < len(s) && s[i] >= 0x20 && s[i] < 0x40 { + i++ + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + func TestTextStreamsConcatenated(t *testing.T) { t.Parallel() var buf bytes.Buffer @@ -49,107 +67,189 @@ func TestThinkingToTextInsertsNewline(t *testing.T) { } } -func TestToolCallWithArgs(t *testing.T) { +func TestToolExecStartBash(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolCall("bash", map[string]any{"command": "ls -la"}) + r.ToolExecStart("t1", "bash", map[string]any{"command": "echo hi"}) got := buf.String() - if !strings.Contains(got, "šŸ”§ bash") { - t.Errorf("missing prefix: %q", got) + if !strings.Contains(got, "ā”Œā”€ ⚔ bash") { + t.Errorf("expected top-of-box header, got %q", got) + } + if !strings.Contains(got, "echo hi") { + t.Errorf("expected literal command, got %q", got) + } + if strings.Contains(got, `"command"`) { + t.Errorf("bash should render literal command, not JSON: %q", got) } - if !strings.Contains(got, `"command":"ls -la"`) { - t.Errorf("missing args JSON: %q", got) +} + +func TestToolExecStartNonBash(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + r.ToolExecStart("t1", "http", map[string]any{"url": "https://example.com"}) + got := buf.String() + if !strings.Contains(got, "ā”Œā”€ ⚔ http") { + t.Errorf("expected top-of-box header, got %q", got) } - if !strings.HasSuffix(got, "\n") { - t.Errorf("output should end with newline: %q", got) + if !strings.Contains(got, `"url":"https://example.com"`) { + t.Errorf("expected JSON args, got %q", got) } } -func TestToolCallNoArgs(t *testing.T) { +func TestToolExecStartNoArgs(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolCall("ping", nil) + r.ToolExecStart("t1", "ping", nil) got := buf.String() - if !strings.Contains(got, "šŸ”§ ping") { - t.Errorf("missing prefix: %q", got) + if !strings.Contains(got, "ā”Œā”€ ⚔ ping") { + t.Errorf("expected ā”Œā”€ ⚔ ping, got %q", got) } if strings.Contains(got, "{}") { t.Errorf("should not render empty args object: %q", got) } } -func TestToolExecStartBash(t *testing.T) { +func TestToolExecUpdateStreamsCompleteLinesOnly(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + + r.ToolExecUpdate("t1", "line 1\nline ") + got := stripANSI(buf.String()) + if !strings.Contains(got, "│ line 1\n") { + t.Errorf("expected complete line emitted, got %q", got) + } + if strings.Contains(got, "│ line \n") || strings.Contains(got, "│ line 2") { + t.Errorf("partial line should be buffered, got %q", got) + } + + r.ToolExecUpdate("t1", "line 1\nline 2\n") + got = stripANSI(buf.String()) + if !strings.Contains(got, "│ line 2\n") { + t.Errorf("expected second line emitted after newline arrived, got %q", got) + } +} + +func TestToolExecUpdateIgnoresShrinkingSnapshot(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + r.ToolExecUpdate("t1", "a\nb\n") + beforeLen := buf.Len() + r.ToolExecUpdate("t1", "a\n") // shorter — pi shouldn't do this, but be safe + if buf.Len() != beforeLen { + t.Errorf("shorter snapshot should be ignored, but buffer grew by %d", buf.Len()-beforeLen) + } +} + +func TestToolExecEndFlushesBufferedPartialLine(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolExecStart("bash", map[string]any{"command": "echo hi"}) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + r.ToolExecUpdate("t1", "trailing without newline") + r.ToolExecEnd("t1", false, "trailing without newline") + got := stripANSI(buf.String()) + if !strings.Contains(got, "│ trailing without newline\n") { + t.Errorf("expected buffered partial line flushed on end, got %q", got) + } +} + +func TestToolExecEndConsumesUnreportedTail(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + // No update arrived — fullText carries everything. + r.ToolExecEnd("t1", false, "line A\nline B\n") + got := stripANSI(buf.String()) + if !strings.Contains(got, "│ line A\n") || !strings.Contains(got, "│ line B\n") { + t.Errorf("expected both lines emitted from end-only fullText, got %q", got) + } +} + +func TestToolExecEndSuccessClosesBoxWithGreenCheck(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + r.ToolExecEnd("t1", false, "") got := buf.String() - if !strings.Contains(got, "⚔ bash: echo hi") { - t.Errorf("expected ⚔ bash: echo hi, got %q", got) + if !strings.Contains(got, "└─") || !strings.Contains(got, "āœ“ bash") { + t.Errorf("expected closing line with āœ“, got %q", got) } - if strings.Contains(got, `"command"`) { - t.Errorf("bash should render literal command, not JSON: %q", got) + if !strings.Contains(got, ansiBoldGreen) { + t.Errorf("expected green status, got %q", got) } } -func TestToolExecStartNonBash(t *testing.T) { +func TestToolExecEndErrorClosesBoxWithRedCross(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolExecStart("http", map[string]any{"url": "https://example.com"}) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + r.ToolExecEnd("t1", true, "") got := buf.String() - if !strings.Contains(got, "⚔ http ") { - t.Errorf("expected ⚔ http prefix, got %q", got) + if !strings.Contains(got, "āœ— bash") { + t.Errorf("expected āœ— status, got %q", got) } - if !strings.Contains(got, `"url":"https://example.com"`) { - t.Errorf("expected JSON args, got %q", got) + if !strings.Contains(got, ansiBoldRed) { + t.Errorf("expected red status, got %q", got) } } -func TestToolExecEndSuccess(t *testing.T) { +func TestToolExecEndIncludesLineCountWhenNonZero(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolExecEnd("bash", false, " hello ") + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + r.ToolExecUpdate("t1", "a\nb\nc\n") + r.ToolExecEnd("t1", false, "a\nb\nc\n") got := buf.String() - if !strings.Contains(got, "āœ“ bash → hello") { - t.Errorf("expected āœ“ marker and trimmed summary, got %q", got) + if !strings.Contains(got, "3 lines") { + t.Errorf("expected line count in footer, got %q", got) } } -func TestToolExecEndError(t *testing.T) { +func TestToolExecFullCycleNoOutput(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolExecEnd("bash", true, "boom") + r.ToolExecStart("t1", "edit", map[string]any{"path": "/x"}) + r.ToolExecEnd("t1", false, "") got := buf.String() - if !strings.Contains(got, "āœ— bash → boom") { - t.Errorf("expected āœ— marker, got %q", got) + if !strings.Contains(got, "ā”Œā”€ ⚔ edit") || !strings.Contains(got, "└─") { + t.Errorf("expected both box corners even with no output, got %q", got) + } + if strings.Contains(got, "│ ") { + t.Errorf("no gutter lines expected when output is empty, got %q", got) } } -func TestToolExecEndTruncates(t *testing.T) { +func TestToolExecFullOutputNotTruncated(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - long := strings.Repeat("a", 500) - r.ToolExecEnd("bash", false, long) + r.ToolExecStart("t1", "bash", map[string]any{"command": "x"}) + long := strings.Repeat("x", 500) + "\n" + r.ToolExecUpdate("t1", long) + r.ToolExecEnd("t1", false, long) got := buf.String() - if !strings.Contains(got, "...") { - t.Errorf("expected truncation marker, got %q", got) + if strings.Contains(got, "...") { + t.Errorf("output must not be truncated, got %q", got) } - if strings.Count(got, "a") > summaryMaxLen+10 { - t.Errorf("summary not truncated; len(a)=%d in %q", strings.Count(got, "a"), got) + if !strings.Contains(got, strings.Repeat("x", 500)) { + t.Error("expected full 500-char line preserved") } } func TestMarshalJSONNoHTMLEscape(t *testing.T) { t.Parallel() - // With HTML escaping disabled, raw &, <, > survive verbatim. - // If escaping were enabled they would become &, <, > - // and "&&" / "" would not appear as substrings. got := marshalJSON(map[string]any{"cmd": "a && b "}) for _, want := range []string{"&&", ""} { if !strings.Contains(got, want) { @@ -162,19 +262,20 @@ func TestEnsureNewlineMidLine(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.Text("hello") // mid-line - r.ToolCall("ping", nil) // must inject \n first + r.Text("hello") // mid-line + r.ToolExecStart("t1", "ping", nil) got := buf.String() if !strings.HasPrefix(got, "hello\n") { - t.Errorf("expected newline injected before tool call, got %q", got) + t.Errorf("expected newline injected before tool box, got %q", got) } } -func TestTextAfterToolCallNoExtraNewline(t *testing.T) { +func TestTextAfterToolExecEndNoExtraNewline(t *testing.T) { t.Parallel() var buf bytes.Buffer r := New(&buf) - r.ToolCall("ping", nil) // ends with \n + r.ToolExecStart("t1", "ping", nil) + r.ToolExecEnd("t1", false, "") r.Text("done") got := buf.String() if strings.Contains(got, "\n\n") { @@ -198,20 +299,72 @@ func TestTurnAndAgentEndOnFreshLine(t *testing.T) { } } -func TestTruncate(t *testing.T) { +func TestParallelToolsRenderSequentially(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + // Two parallel tools: starts arrive before the first end. + r.ToolExecStart("a", "bash", map[string]any{"command": "echo hello"}) + r.ToolExecStart("b", "bash", map[string]any{"command": "echo world"}) + r.ToolExecUpdate("a", "hello\n") + r.ToolExecUpdate("b", "world\n") + r.ToolExecEnd("a", false, "hello\n") + r.ToolExecEnd("b", false, "world\n") + + got := stripANSI(buf.String()) + // First box: header A, hello, footer A. Second box: header B, world, footer B. + idxHeaderA := strings.Index(got, "echo hello") + idxHello := strings.Index(got, "│ hello") + idxFooterA := strings.Index(got, "āœ“ bash") + idxHeaderB := strings.Index(got, "echo world") + idxWorld := strings.Index(got, "│ world") + idxFooterB := strings.LastIndex(got, "āœ“ bash") + + if idxHeaderA < 0 || idxHello < 0 || idxFooterA < 0 || idxHeaderB < 0 || idxWorld < 0 { + t.Fatalf("missing parts in output:\n%s", got) + } + if !(idxHeaderA < idxHello && idxHello < idxFooterA && idxFooterA < idxHeaderB && idxHeaderB < idxWorld && idxWorld < idxFooterB) { + t.Errorf("expected sequential ordering A-header, A-line, A-footer, B-header, B-line, B-footer; got:\n%s", got) + } +} + +func TestParallelToolEndsBeforeFirstFinishes(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := New(&buf) + // B starts and ends while A is still active. B's box must be flushed + // after A's, with its full content. + r.ToolExecStart("a", "bash", map[string]any{"command": "long-running"}) + r.ToolExecStart("b", "bash", map[string]any{"command": "quick"}) + r.ToolExecEnd("b", false, "quick output\n") + // No B output should appear yet — A is still active. + mid := stripANSI(buf.String()) + if strings.Contains(mid, "│ quick output") { + t.Errorf("B output leaked while A still active:\n%s", mid) + } + r.ToolExecEnd("a", false, "") + got := stripANSI(buf.String()) + if !strings.Contains(got, "│ quick output") { + t.Errorf("B output should appear after A closes:\n%s", got) + } +} + +func TestFormatDuration(t *testing.T) { t.Parallel() cases := []struct { - in string - maxLen int - want string + ms int + want string }{ - {"abc", 10, "abc"}, - {"abcdef", 3, "abc..."}, - {"", 5, ""}, + {0, "0ms"}, + {42, "42ms"}, + {999, "999ms"}, + {1000, "1.0s"}, + {1500, "1.5s"}, } for _, c := range cases { - if got := truncate(c.in, c.maxLen); got != c.want { - t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.maxLen, got, c.want) + got := formatDuration(time.Duration(c.ms) * time.Millisecond) + if got != c.want { + t.Errorf("formatDuration(%dms) = %q, want %q", c.ms, got, c.want) } } }