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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions internal/tui/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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")
Expand All @@ -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)
Expand Down
93 changes: 82 additions & 11 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
143 changes: 143 additions & 0 deletions internal/tui/steps_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 4 additions & 0 deletions internal/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),

Expand Down
Loading