From 882880b4d53a9bb10cec2a2de3abf6f635a8f835 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 07:09:13 +0000 Subject: [PATCH] feat(tui): left-align start page, enrich tool result & sub-agent rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start page: the welcome key column is now flush-left (padRight) instead of right-aligned, so commands and their descriptions share a clean left edge. Tool results: tool_result now retains a sanitized, line-capped excerpt (resultPreview) instead of a single collapsed line. renderSteps draws it as a tree (⎿) — multi-line, blank-stripped, capped at 5 lines with a "+N more lines" footer — and tints the status glyph ✗ when the output reads as a failure (looksLikeError). argPreview now also surfaces prompt/task/description fields so delegations show their task. Sub-agents: a delegating tool_call is flagged via isSubagent and labelled "sub-agent"; subsequent subagent_log events nest beneath it as its own branch of the step tree (attachSubLog), falling back to a notice when there is no wrapping step. Tests cover padRight, isSubagent, looksLikeError, resultExcerpt, resultPreview, sub-agent log nesting, and the enriched step rendering; internal/tui statement coverage holds at 96%+. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHpZVF8Lm1cyZXM6L4a6vn --- README.md | 7 +- internal/tui/banner.go | 14 +++- internal/tui/model.go | 93 +++++++++++++++++++++--- internal/tui/steps_test.go | 143 +++++++++++++++++++++++++++++++++++++ internal/tui/styles.go | 4 ++ internal/tui/view.go | 58 ++++++++++++--- 6 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 internal/tui/steps_test.go diff --git a/README.md b/README.md index b4712f4..c065b25 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,12 @@ When the agent requests approval for a dangerous operation, answer inline: - **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)). - **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph - per tool, a spinner, an argument preview, and a one-line result. + per tool, a spinner, an argument preview, and a result excerpt rendered as a + tree (`⎿`) — multi-line, blank-stripped, capped with a `+N more lines` + footer, and tinted with a `✗` when the call fails. +- **Sub-agents** — delegations are labelled and their `subagent_log` activity + nests beneath the delegating call, so a sub-agent's progress reads as its own + branch of the step tree. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 746c220..1c5d018 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -40,7 +40,8 @@ func welcome(th theme, width int, cwd string) string { } b.WriteByte('\n') - // key column is right-aligned to a fixed width so the descriptions line up. + // key column is left-aligned to a fixed width so both the keys and their + // descriptions line up on a flush left edge. tips := [][2]string{ {"type a task", "and press enter to run the agent"}, {"/ commands", "type / for commands, e.g. /help /sessions /model"}, @@ -52,7 +53,7 @@ func welcome(th theme, width int, cwd string) string { } const keyW = 11 for _, t := range tips { - b.WriteString(th.tipKey.Render(padLeft(t[0], keyW)) + " " + th.tipText.Render(t[1]) + "\n") + b.WriteString(th.tipKey.Render(padRight(t[0], keyW)) + " " + th.tipText.Render(t[1]) + "\n") } block := strings.TrimRight(b.String(), "\n") @@ -69,6 +70,15 @@ func padLeft(s string, n int) string { return strings.Repeat(" ", n-w) + s } +// padRight right-pads s with spaces to width n (left-aligns within the column). +func padRight(s string, n int) string { + w := lipgloss.Width(s) + if w >= n { + return s + } + return s + strings.Repeat(" ", n-w) +} + // shortenHome replaces a leading $HOME with "~" for a compact, readable path. func shortenHome(p string) string { p = strings.TrimSpace(p) diff --git a/internal/tui/model.go b/internal/tui/model.go index 97dbeb3..3dd00b5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -28,10 +28,13 @@ const ( // step is a single tool invocation within an assistant turn. type step struct { - name string - arg string - result string - done bool + name string + arg string + result string // sanitized tool output (multi-line); excerpted at render + done bool + isErr bool // the result reads as a failure (tints the status glyph red) + subagent bool // this call delegates to a sub-agent (renders its log tree) + logs []string // nested sub-agent activity, from subagent_log events } // turnStats is the telemetry of one finalized assistant turn, captured from the @@ -402,7 +405,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "tool_call": arg := argPreview(ev.Data) if i := m.cur(); i >= 0 { - m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: arg}) + m.msgs[i].steps = append(m.msgs[i].steps, + step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name)}) } m.lastTool = ev.Name m.lastArg = arg @@ -414,7 +418,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { for j := len(steps) - 1; j >= 0; j-- { if steps[j].name == ev.Name && !steps[j].done { steps[j].done = true - steps[j].result = linePreview(ev.Data) + steps[j].result = resultPreview(ev.Data) + steps[j].isErr = looksLikeError(steps[j].result) break } } @@ -476,7 +481,17 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "agent_signal": m.addNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) case "subagent_log": - m.addNote("subagent · " + strings.TrimSpace(ev.SubType+" "+ev.Name) + eventTail(ev)) + line := strings.TrimSpace(ev.SubType + " " + ev.Name) + if d := collapse(ev.Detail); d != "" { + line = strings.TrimSpace(line + " · " + d) + } + line += eventTail(ev) + // Nest the log under the in-flight sub-agent step when there is one; + // otherwise (resumed turn, idle, or an unwrapped log) keep it as a notice. + if i := m.cur(); i >= 0 && m.attachSubLog(i, line) { + break + } + m.addNote("subagent · " + line) case client.EventDisconnected: m.disconn = true @@ -829,7 +844,10 @@ func argPreview(data string) string { if err := json.Unmarshal([]byte(data), &m); err != nil { return truncate(collapse(data), 72) } - for _, key := range []string{"command", "cmd", "path", "file", "pattern", "query", "url"} { + for _, key := range []string{ + "command", "cmd", "path", "file", "pattern", "query", "url", + "prompt", "task", "description", "instruction", + } { if v, ok := m[key]; ok { if s, ok := v.(string); ok && s != "" { return truncate(collapse(s), 72) @@ -845,9 +863,62 @@ func argPreview(data string) string { return truncate(collapse(strings.Join(parts, " ")), 72) } -// linePreview returns the first meaningful line of tool output, truncated. -func linePreview(data string) string { - return truncate(collapse(data), 72) +// resultPreview sanitizes tool output and caps it to a generous number of +// lines, so the transcript can show a useful excerpt (rendered by renderSteps) +// without retaining the unbounded output of a chatty tool. +func resultPreview(data string) string { + s := sanitize(data) + lines := strings.Split(s, "\n") + const cap = 200 + if len(lines) > cap { + lines = lines[:cap] + } + return strings.Join(lines, "\n") +} + +// isSubagent reports whether a tool name denotes a sub-agent delegation. The +// substrings mirror toolGlyph / toolProgress so the three stay consistent. +func isSubagent(name string) bool { + n := strings.ToLower(name) + return strings.Contains(n, "delegate") || + strings.Contains(n, "subagent") || + strings.Contains(n, "task") +} + +// looksLikeError reports whether a tool result reads as a failure. It is +// deliberately conservative — keyed off leading error tokens and a couple of +// unambiguous shell phrases — so ordinary output that merely mentions "error" +// is not tinted red. +func looksLikeError(s string) bool { + t := strings.ToLower(strings.TrimSpace(s)) + switch { + case strings.HasPrefix(t, "error"), + strings.HasPrefix(t, "fatal"), + strings.HasPrefix(t, "panic:"), + strings.HasPrefix(t, "traceback"), + strings.HasPrefix(t, "exception"), + strings.HasPrefix(t, "exit status"): + return true + } + return strings.Contains(t, "command not found") || + strings.Contains(t, "no such file or directory") +} + +// attachSubLog appends a sub-agent activity line to the most recent sub-agent +// step in message i, reporting whether one was found. +func (m *Model) attachSubLog(i int, line string) bool { + const maxSubLogs = 8 + steps := m.msgs[i].steps + for j := len(steps) - 1; j >= 0; j-- { + if !steps[j].subagent { + continue + } + if len(steps[j].logs) < maxSubLogs { + steps[j].logs = append(steps[j].logs, sanitize(line)) + } + return true + } + return false } func collapse(s string) string { diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go new file mode 100644 index 0000000..140a35c --- /dev/null +++ b/internal/tui/steps_test.go @@ -0,0 +1,143 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestPadRight(t *testing.T) { + if padRight("ab", 4) != "ab " { + t.Errorf("padRight short: %q", padRight("ab", 4)) + } + if padRight("abcd", 2) != "abcd" { + t.Errorf("padRight overflow: %q", padRight("abcd", 2)) + } +} + +func TestIsSubagent(t *testing.T) { + for _, n := range []string{"task", "delegate_task", "Subagent", "spawn_subagent"} { + if !isSubagent(n) { + t.Errorf("isSubagent(%q) = false, want true", n) + } + } + for _, n := range []string{"shell", "read_file", "grep"} { + if isSubagent(n) { + t.Errorf("isSubagent(%q) = true, want false", n) + } + } +} + +func TestLooksLikeError(t *testing.T) { + errs := []string{ + "Error: boom", "fatal: not a git repo", "panic: nil deref", + "Traceback (most recent call last):", "exit status 1", + "bash: foo: command not found", "open x: no such file or directory", + } + for _, s := range errs { + if !looksLikeError(s) { + t.Errorf("looksLikeError(%q) = false, want true", s) + } + } + oks := []string{"", "ok", "found 3 matches mentioning error", "PASS"} + for _, s := range oks { + if looksLikeError(s) { + t.Errorf("looksLikeError(%q) = true, want false", s) + } + } +} + +func TestResultExcerpt(t *testing.T) { + // Blank lines are dropped; short output is returned whole. + got := resultExcerpt("a\n\n \nb") + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("resultExcerpt blanks: %#v", got) + } + // Long output is capped with a "+N more lines" footer. + var lines []string + for i := 0; i < 9; i++ { + lines = append(lines, "line") + } + got = resultExcerpt(strings.Join(lines, "\n")) + if len(got) != 6 { // 5 + footer + t.Fatalf("resultExcerpt cap: %#v", got) + } + if !strings.Contains(got[5], "+4 more lines") { + t.Errorf("missing overflow footer: %q", got[5]) + } +} + +func TestResultPreviewCapsAndSanitizes(t *testing.T) { + var b strings.Builder + for i := 0; i < 300; i++ { + b.WriteString("x\n") + } + out := resultPreview(b.String() + "tail\x1b[2J") + if strings.ContainsRune(out, '\x1b') { + t.Error("resultPreview left an escape byte") + } + if n := strings.Count(out, "\n"); n > 200 { + t.Errorf("resultPreview did not cap lines: %d", n) + } +} + +// TestSubagentLogNesting verifies a subagent_log lands under the in-flight +// sub-agent step when one exists, and falls back to a notice otherwise. +func TestSubagentLogNesting(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + // A non-sub-agent tool: the log has nowhere to nest → notice. + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`}) + m.handleEvent(client.Event{Type: "subagent_log", SubType: "started", Name: "explorer"}) + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "subagent · started explorer") { + t.Errorf("expected fallback notice, notices=%q", got) + } + + // A sub-agent tool: subsequent logs nest under its step. + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_task", Data: `{"task":"explore the repo"}`}) + m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Detail: "main.go"}) + step := m.msgs[0].steps[len(m.msgs[0].steps)-1] + if !step.subagent { + t.Fatal("delegate step not flagged as sub-agent") + } + if len(step.logs) != 1 || !strings.Contains(step.logs[0], "read") { + t.Errorf("sub-agent log not nested: %#v", step.logs) + } +} + +// TestRenderStepsSubagentAndError exercises the enriched step rendering: a +// sub-agent label, a nested log tree, and an error-tinted result. +func TestRenderStepsSubagentAndError(t *testing.T) { + m := newTestModel() + msg := message{ + role: roleAsst, + streaming: false, + steps: []step{ + {name: "delegate_task", arg: "explore", subagent: true, done: true, + logs: []string{"started explorer"}, result: "done"}, + {name: "shell", arg: "go test", done: true, isErr: true, + result: "exit status 1\nFAIL"}, + }, + } + out := plain(m.renderSteps(msg)) + for _, want := range []string{"sub-agent", "⎿", "explorer", "✗", "exit status 1"} { + if !strings.Contains(out, want) { + t.Errorf("renderSteps missing %q in:\n%s", want, out) + } + } + + // Streaming turn: a not-done step renders the live spinner; a not-done step + // in a finalized turn renders the pending glyph. Also drive the narrow-width + // budget floor. + m.vp.Width = 8 + if s := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}); s == "" { + t.Error("streaming step rendered empty") + } + if s := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}); !strings.Contains(plain(s), "▸") { + t.Errorf("pending step missing ▸ glyph: %q", plain(s)) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 6390be0..2734bdf 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -47,7 +47,9 @@ type theme struct { stepArg lipgloss.Style stepRun lipgloss.Style stepDone lipgloss.Style + stepErr lipgloss.Style stepRes lipgloss.Style + stepTree lipgloss.Style spinner lipgloss.Style @@ -123,7 +125,9 @@ func newTheme() theme { stepArg: lipgloss.NewStyle().Foreground(colFaint), stepRun: lipgloss.NewStyle().Foreground(colYellow), stepDone: lipgloss.NewStyle().Foreground(colGreen), + stepErr: lipgloss.NewStyle().Foreground(colRed).Bold(true), stepRes: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + stepTree: lipgloss.NewStyle().Foreground(colHairline), spinner: lipgloss.NewStyle().Foreground(colBrand), diff --git a/internal/tui/view.go b/internal/tui/view.go index 3563d27..bc7f6ee 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -360,30 +360,70 @@ func (m *Model) renderSteps(msg message) string { return "" } th := m.th - lines := make([]string, 0, len(msg.steps)) + // Detail lines (sub-agent logs, then the result excerpt) hang under each tool + // line on a light tree connector; clamp them to the assistant bar's width + // (border + padding + the 4-column connector) so they never wrap. + budget := m.vp.Width - 8 + if budget < 16 { + budget = 16 + } + lines := make([]string, 0, len(msg.steps)*2) for _, s := range msg.steps { + // Status glyph: a spinner while the call runs, then ✓ / ✗ once it lands. var icon string switch { + case !s.done && msg.streaming: + icon = th.spinner.Render(m.sp.View()) + case s.done && s.isErr: + icon = th.stepErr.Render("✗") case s.done: icon = th.stepDone.Render("✓") - case msg.streaming: - icon = th.spinner.Render(m.sp.View()) default: icon = th.stepRun.Render("▸") } - glyph := th.toolIcon.Render(toolGlyph(s.name)) - line := icon + " " + glyph + " " + th.stepName.Render(s.name) + head := icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + th.stepName.Render(s.name) + if s.subagent { + head += th.stepArg.Render(" · sub-agent") + } if s.arg != "" { - line += th.stepArg.Render(" " + s.arg) + head += th.stepArg.Render(" " + truncate(s.arg, budget)) } - lines = append(lines, line) - if s.done && s.result != "" { - lines = append(lines, th.stepRes.Render(" ↳ "+s.result)) + lines = append(lines, head) + + // Nested sub-agent activity, then the tool's own output. + details := append([]string{}, s.logs...) + if s.done { + details = append(details, resultExcerpt(s.result)...) + } + for i, d := range details { + conn := " " + if i == 0 { + conn = " ⎿ " + } + lines = append(lines, th.stepTree.Render(conn)+th.stepRes.Render(truncate(d, budget))) } } return strings.Join(lines, "\n") } +// resultExcerpt turns sanitized tool output into a compact, blank-stripped +// preview: up to maxResultLines meaningful lines, with a "+N more lines" +// footer when the output runs longer. +func resultExcerpt(result string) []string { + const maxResultLines = 5 + var out []string + for _, ln := range strings.Split(result, "\n") { + if c := collapse(ln); c != "" { + out = append(out, c) + } + } + if len(out) <= maxResultLines { + return out + } + trimmed := append([]string{}, out[:maxResultLines]...) + return append(trimmed, fmt.Sprintf("… +%d more lines", len(out)-maxResultLines)) +} + func (m *Model) renderNotices() string { th := m.th lines := make([]string, len(m.notices))