Skip to content

Commit 8e63cae

Browse files
authored
Merge pull request #3 from BackendStack21/claude/bodek-sessions-websocket-ports-fzraok
feat(tui): left-align start page, enrich tool result & sub-agent rendering
2 parents 426c4b7 + 882880b commit 8e63cae

6 files changed

Lines changed: 296 additions & 23 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,12 @@ When the agent requests approval for a dangerous operation, answer inline:
137137

138138
- **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)).
139139
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
140-
per tool, a spinner, an argument preview, and a one-line result.
140+
per tool, a spinner, an argument preview, and a result excerpt rendered as a
141+
tree (``) — multi-line, blank-stripped, capped with a `+N more lines`
142+
footer, and tinted with a `` when the call fails.
143+
- **Sub-agents** — delegations are labelled and their `subagent_log` activity
144+
nests beneath the delegating call, so a sub-agent's progress reads as its own
145+
branch of the step tree.
141146
- **Security approvals** — odek's `danger` engine prompts surface as an inline
142147
panel; your answer is sent straight back over the socket.
143148
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text,

internal/tui/banner.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ func welcome(th theme, width int, cwd string) string {
4040
}
4141
b.WriteByte('\n')
4242

43-
// key column is right-aligned to a fixed width so the descriptions line up.
43+
// key column is left-aligned to a fixed width so both the keys and their
44+
// descriptions line up on a flush left edge.
4445
tips := [][2]string{
4546
{"type a task", "and press enter to run the agent"},
4647
{"/ commands", "type / for commands, e.g. /help /sessions /model"},
@@ -52,7 +53,7 @@ func welcome(th theme, width int, cwd string) string {
5253
}
5354
const keyW = 11
5455
for _, t := range tips {
55-
b.WriteString(th.tipKey.Render(padLeft(t[0], keyW)) + " " + th.tipText.Render(t[1]) + "\n")
56+
b.WriteString(th.tipKey.Render(padRight(t[0], keyW)) + " " + th.tipText.Render(t[1]) + "\n")
5657
}
5758

5859
block := strings.TrimRight(b.String(), "\n")
@@ -69,6 +70,15 @@ func padLeft(s string, n int) string {
6970
return strings.Repeat(" ", n-w) + s
7071
}
7172

73+
// padRight right-pads s with spaces to width n (left-aligns within the column).
74+
func padRight(s string, n int) string {
75+
w := lipgloss.Width(s)
76+
if w >= n {
77+
return s
78+
}
79+
return s + strings.Repeat(" ", n-w)
80+
}
81+
7282
// shortenHome replaces a leading $HOME with "~" for a compact, readable path.
7383
func shortenHome(p string) string {
7484
p = strings.TrimSpace(p)

internal/tui/model.go

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ const (
2828

2929
// step is a single tool invocation within an assistant turn.
3030
type step struct {
31-
name string
32-
arg string
33-
result string
34-
done bool
31+
name string
32+
arg string
33+
result string // sanitized tool output (multi-line); excerpted at render
34+
done bool
35+
isErr bool // the result reads as a failure (tints the status glyph red)
36+
subagent bool // this call delegates to a sub-agent (renders its log tree)
37+
logs []string // nested sub-agent activity, from subagent_log events
3538
}
3639

3740
// 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) {
402405
case "tool_call":
403406
arg := argPreview(ev.Data)
404407
if i := m.cur(); i >= 0 {
405-
m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: arg})
408+
m.msgs[i].steps = append(m.msgs[i].steps,
409+
step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name)})
406410
}
407411
m.lastTool = ev.Name
408412
m.lastArg = arg
@@ -414,7 +418,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
414418
for j := len(steps) - 1; j >= 0; j-- {
415419
if steps[j].name == ev.Name && !steps[j].done {
416420
steps[j].done = true
417-
steps[j].result = linePreview(ev.Data)
421+
steps[j].result = resultPreview(ev.Data)
422+
steps[j].isErr = looksLikeError(steps[j].result)
418423
break
419424
}
420425
}
@@ -476,7 +481,17 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
476481
case "agent_signal":
477482
m.addNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev))
478483
case "subagent_log":
479-
m.addNote("subagent · " + strings.TrimSpace(ev.SubType+" "+ev.Name) + eventTail(ev))
484+
line := strings.TrimSpace(ev.SubType + " " + ev.Name)
485+
if d := collapse(ev.Detail); d != "" {
486+
line = strings.TrimSpace(line + " · " + d)
487+
}
488+
line += eventTail(ev)
489+
// Nest the log under the in-flight sub-agent step when there is one;
490+
// otherwise (resumed turn, idle, or an unwrapped log) keep it as a notice.
491+
if i := m.cur(); i >= 0 && m.attachSubLog(i, line) {
492+
break
493+
}
494+
m.addNote("subagent · " + line)
480495

481496
case client.EventDisconnected:
482497
m.disconn = true
@@ -829,7 +844,10 @@ func argPreview(data string) string {
829844
if err := json.Unmarshal([]byte(data), &m); err != nil {
830845
return truncate(collapse(data), 72)
831846
}
832-
for _, key := range []string{"command", "cmd", "path", "file", "pattern", "query", "url"} {
847+
for _, key := range []string{
848+
"command", "cmd", "path", "file", "pattern", "query", "url",
849+
"prompt", "task", "description", "instruction",
850+
} {
833851
if v, ok := m[key]; ok {
834852
if s, ok := v.(string); ok && s != "" {
835853
return truncate(collapse(s), 72)
@@ -845,9 +863,62 @@ func argPreview(data string) string {
845863
return truncate(collapse(strings.Join(parts, " ")), 72)
846864
}
847865

848-
// linePreview returns the first meaningful line of tool output, truncated.
849-
func linePreview(data string) string {
850-
return truncate(collapse(data), 72)
866+
// resultPreview sanitizes tool output and caps it to a generous number of
867+
// lines, so the transcript can show a useful excerpt (rendered by renderSteps)
868+
// without retaining the unbounded output of a chatty tool.
869+
func resultPreview(data string) string {
870+
s := sanitize(data)
871+
lines := strings.Split(s, "\n")
872+
const cap = 200
873+
if len(lines) > cap {
874+
lines = lines[:cap]
875+
}
876+
return strings.Join(lines, "\n")
877+
}
878+
879+
// isSubagent reports whether a tool name denotes a sub-agent delegation. The
880+
// substrings mirror toolGlyph / toolProgress so the three stay consistent.
881+
func isSubagent(name string) bool {
882+
n := strings.ToLower(name)
883+
return strings.Contains(n, "delegate") ||
884+
strings.Contains(n, "subagent") ||
885+
strings.Contains(n, "task")
886+
}
887+
888+
// looksLikeError reports whether a tool result reads as a failure. It is
889+
// deliberately conservative — keyed off leading error tokens and a couple of
890+
// unambiguous shell phrases — so ordinary output that merely mentions "error"
891+
// is not tinted red.
892+
func looksLikeError(s string) bool {
893+
t := strings.ToLower(strings.TrimSpace(s))
894+
switch {
895+
case strings.HasPrefix(t, "error"),
896+
strings.HasPrefix(t, "fatal"),
897+
strings.HasPrefix(t, "panic:"),
898+
strings.HasPrefix(t, "traceback"),
899+
strings.HasPrefix(t, "exception"),
900+
strings.HasPrefix(t, "exit status"):
901+
return true
902+
}
903+
return strings.Contains(t, "command not found") ||
904+
strings.Contains(t, "no such file or directory")
905+
}
906+
907+
// attachSubLog appends a sub-agent activity line to the most recent sub-agent
908+
// step in message i, reporting whether one was found.
909+
func (m *Model) attachSubLog(i int, line string) bool {
910+
const maxSubLogs = 8
911+
steps := m.msgs[i].steps
912+
for j := len(steps) - 1; j >= 0; j-- {
913+
if !steps[j].subagent {
914+
continue
915+
}
916+
if len(steps[j].logs) < maxSubLogs {
917+
steps[j].logs = append(steps[j].logs, sanitize(line))
918+
}
919+
return true
920+
}
921+
return false
851922
}
852923

853924
func collapse(s string) string {

internal/tui/steps_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/BackendStack21/bodek/internal/client"
8+
)
9+
10+
func TestPadRight(t *testing.T) {
11+
if padRight("ab", 4) != "ab " {
12+
t.Errorf("padRight short: %q", padRight("ab", 4))
13+
}
14+
if padRight("abcd", 2) != "abcd" {
15+
t.Errorf("padRight overflow: %q", padRight("abcd", 2))
16+
}
17+
}
18+
19+
func TestIsSubagent(t *testing.T) {
20+
for _, n := range []string{"task", "delegate_task", "Subagent", "spawn_subagent"} {
21+
if !isSubagent(n) {
22+
t.Errorf("isSubagent(%q) = false, want true", n)
23+
}
24+
}
25+
for _, n := range []string{"shell", "read_file", "grep"} {
26+
if isSubagent(n) {
27+
t.Errorf("isSubagent(%q) = true, want false", n)
28+
}
29+
}
30+
}
31+
32+
func TestLooksLikeError(t *testing.T) {
33+
errs := []string{
34+
"Error: boom", "fatal: not a git repo", "panic: nil deref",
35+
"Traceback (most recent call last):", "exit status 1",
36+
"bash: foo: command not found", "open x: no such file or directory",
37+
}
38+
for _, s := range errs {
39+
if !looksLikeError(s) {
40+
t.Errorf("looksLikeError(%q) = false, want true", s)
41+
}
42+
}
43+
oks := []string{"", "ok", "found 3 matches mentioning error", "PASS"}
44+
for _, s := range oks {
45+
if looksLikeError(s) {
46+
t.Errorf("looksLikeError(%q) = true, want false", s)
47+
}
48+
}
49+
}
50+
51+
func TestResultExcerpt(t *testing.T) {
52+
// Blank lines are dropped; short output is returned whole.
53+
got := resultExcerpt("a\n\n \nb")
54+
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
55+
t.Errorf("resultExcerpt blanks: %#v", got)
56+
}
57+
// Long output is capped with a "+N more lines" footer.
58+
var lines []string
59+
for i := 0; i < 9; i++ {
60+
lines = append(lines, "line")
61+
}
62+
got = resultExcerpt(strings.Join(lines, "\n"))
63+
if len(got) != 6 { // 5 + footer
64+
t.Fatalf("resultExcerpt cap: %#v", got)
65+
}
66+
if !strings.Contains(got[5], "+4 more lines") {
67+
t.Errorf("missing overflow footer: %q", got[5])
68+
}
69+
}
70+
71+
func TestResultPreviewCapsAndSanitizes(t *testing.T) {
72+
var b strings.Builder
73+
for i := 0; i < 300; i++ {
74+
b.WriteString("x\n")
75+
}
76+
out := resultPreview(b.String() + "tail\x1b[2J")
77+
if strings.ContainsRune(out, '\x1b') {
78+
t.Error("resultPreview left an escape byte")
79+
}
80+
if n := strings.Count(out, "\n"); n > 200 {
81+
t.Errorf("resultPreview did not cap lines: %d", n)
82+
}
83+
}
84+
85+
// TestSubagentLogNesting verifies a subagent_log lands under the in-flight
86+
// sub-agent step when one exists, and falls back to a notice otherwise.
87+
func TestSubagentLogNesting(t *testing.T) {
88+
m := newTestModel()
89+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
90+
m.curIdx = 0
91+
m.busy = true
92+
93+
// A non-sub-agent tool: the log has nowhere to nest → notice.
94+
m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"ls"}`})
95+
m.handleEvent(client.Event{Type: "subagent_log", SubType: "started", Name: "explorer"})
96+
if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "subagent · started explorer") {
97+
t.Errorf("expected fallback notice, notices=%q", got)
98+
}
99+
100+
// A sub-agent tool: subsequent logs nest under its step.
101+
m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_task", Data: `{"task":"explore the repo"}`})
102+
m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Detail: "main.go"})
103+
step := m.msgs[0].steps[len(m.msgs[0].steps)-1]
104+
if !step.subagent {
105+
t.Fatal("delegate step not flagged as sub-agent")
106+
}
107+
if len(step.logs) != 1 || !strings.Contains(step.logs[0], "read") {
108+
t.Errorf("sub-agent log not nested: %#v", step.logs)
109+
}
110+
}
111+
112+
// TestRenderStepsSubagentAndError exercises the enriched step rendering: a
113+
// sub-agent label, a nested log tree, and an error-tinted result.
114+
func TestRenderStepsSubagentAndError(t *testing.T) {
115+
m := newTestModel()
116+
msg := message{
117+
role: roleAsst,
118+
streaming: false,
119+
steps: []step{
120+
{name: "delegate_task", arg: "explore", subagent: true, done: true,
121+
logs: []string{"started explorer"}, result: "done"},
122+
{name: "shell", arg: "go test", done: true, isErr: true,
123+
result: "exit status 1\nFAIL"},
124+
},
125+
}
126+
out := plain(m.renderSteps(msg))
127+
for _, want := range []string{"sub-agent", "⎿", "explorer", "✗", "exit status 1"} {
128+
if !strings.Contains(out, want) {
129+
t.Errorf("renderSteps missing %q in:\n%s", want, out)
130+
}
131+
}
132+
133+
// Streaming turn: a not-done step renders the live spinner; a not-done step
134+
// in a finalized turn renders the pending glyph. Also drive the narrow-width
135+
// budget floor.
136+
m.vp.Width = 8
137+
if s := m.renderSteps(message{streaming: true, steps: []step{{name: "read", arg: "x"}}}); s == "" {
138+
t.Error("streaming step rendered empty")
139+
}
140+
if s := m.renderSteps(message{streaming: false, steps: []step{{name: "read"}}}); !strings.Contains(plain(s), "▸") {
141+
t.Errorf("pending step missing ▸ glyph: %q", plain(s))
142+
}
143+
}

internal/tui/styles.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ type theme struct {
4747
stepArg lipgloss.Style
4848
stepRun lipgloss.Style
4949
stepDone lipgloss.Style
50+
stepErr lipgloss.Style
5051
stepRes lipgloss.Style
52+
stepTree lipgloss.Style
5153

5254
spinner lipgloss.Style
5355

@@ -123,7 +125,9 @@ func newTheme() theme {
123125
stepArg: lipgloss.NewStyle().Foreground(colFaint),
124126
stepRun: lipgloss.NewStyle().Foreground(colYellow),
125127
stepDone: lipgloss.NewStyle().Foreground(colGreen),
128+
stepErr: lipgloss.NewStyle().Foreground(colRed).Bold(true),
126129
stepRes: lipgloss.NewStyle().Foreground(colFaint).Italic(true),
130+
stepTree: lipgloss.NewStyle().Foreground(colHairline),
127131

128132
spinner: lipgloss.NewStyle().Foreground(colBrand),
129133

0 commit comments

Comments
 (0)