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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 6 additions & 9 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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.
}
}
70 changes: 52 additions & 18 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -93,26 +138,15 @@ 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)
handleMessage(r, &event.AssistantMessageEvent{
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())
}
}
10 changes: 6 additions & 4 deletions internal/event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions internal/render/ansi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading
Loading