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: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,14 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
compact composer footers must fit one terminal row and retain actionable keys.
- Home hints wrap by display cells, including wide Unicode paths. Keep the home
free of a second wordmark or an additional feature dashboard.
- Structured batch summaries must not invent successful execution when normalized
results omit exit/status metadata. Expanded plan and batch views remain behind
existing details controls and preserve the chronological transcript.
- Tool summaries must not invent successful execution when results omit
exit/status metadata. Expanded plan views remain behind existing details
controls and preserve the chronological transcript.
- odek v2.26.0 retired `parallel_shell`, `batch_patch`, `batch_read`, `multi_grep`,
and `http_batch`. Keep historical entries on the generic raw/JSON path; do not
restore typed cards, argument previews, icons, summaries, or result schemas.
Preserve `delegate_tasks`, all `bg_*` views, and supported individual `shell`,
`patch`, `read_file`, `search_files`, and `http_request` rendering.
- `TestTerminalWorkflowLayouts` checks real views across four themes at 40, 80,
and 120 columns. Set `BODEK_RENDER_PREVIEW_DIR` to write optional ANSI fixtures
for visual review without an engine/provider; generated captures are not source.
Expand Down Expand Up @@ -376,11 +381,10 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
connection ends; keep allow-once and deny visible at narrow widths. Default
turn footers right-align outcome, elapsed time, tool count, and known cost; full
telemetry remains under `^E` and `/stats`.
- Keep normalized `step.result` for copying/error compatibility and bounded
`step.detailResult` for structured display. Preserve sanitized command/path
identity through live and history ingestion; never infer item boundaries from
`[N]` text. Report omitted bodies/items and total subset counts explicitly.
Generic previews cap at 128 KiB/200 lines; structured details at 64 KiB/256 items.
- Keep normalized `step.result` for copying/error compatibility. Preserve
sanitized command/path identity through live and history ingestion; never
infer item boundaries from `[N]` text. Generic previews cap at 128 KiB/200
lines and report omitted output explicitly.
- Completed plans include the producer's `all N steps complete` format.
- All footer modes fit one display row, prioritizing primary and exit actions.
Recompute viewport height when the new-output shelf changes. Approval details
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,9 @@ own front-end settings are separate; see [Configuration](#configuration).
structured output only: test verdicts (`✓ 5 passed · 2 skipped`, go
coverage), git commits/pushes (`⎇ a1b2c3d`, `↑ main`), lint results,
compiler warning counts, HTTP statuses, and search hit counts. Prose like
"Build passed" never goes green. Plans show compact progress and step rows;
parallel shell, batched reads/patches, and HTTP batches show item counts and
failures, with per-item detail behind `^E`. Missing success metadata stays
neutral rather than claiming that a command passed.
"Build passed" never goes green. Plans show compact progress and step rows.
Missing success metadata stays neutral rather than claiming that a command
passed.
- **Streaming answers** rendered as Markdown
([glamour](https://github.com/charmbracelet/glamour)).
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
Expand Down Expand Up @@ -457,12 +456,14 @@ Use `PgUp`/`PgDn` to page, `alt+i` to copy the displayed invocation,
The global `^E` details toggle uses the same page limits. Control and
invisible characters in invocations appear as safe escape text.

Batch results retain command/file labels and original item counts; bracketed
log lines are never treated as extra commands. Plans render creation, updates,
blocked steps, and completion. Text previews retain up to 128 KiB and 200 lines;
structured detail metadata is capped at 64 KiB and 256 items. Omitted content
and subset counts are labelled, so a preview is never presented as the full
result when it was shortened.
Plans render creation, updates, blocked steps, and completion. Text previews
retain up to 128 KiB and 200 lines, with omitted content labelled.

odek v2.26.0 retired `parallel_shell`, `batch_patch`, `batch_read`, `multi_grep`,
and `http_batch`. Historical entries for these tools use generic raw/JSON
inspection without typed cards, icons, or summaries. Supported `shell`, `patch`,
`read_file`, `search_files`, and `http_request` rendering remains available,
as do delegation and background-job views.

### The prompt queue

Expand Down
2 changes: 1 addition & 1 deletion internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestListenDrainsPendingBatch(t *testing.T) {
}

func TestGlyphsAllBranches(t *testing.T) {
for _, n := range []string{"shell", "bash", "write_file", "patch", "read_file", "list_dir", "search_files", "web_search", "browser", "http_batch", "delegate_tasks", "memory", "vision", "transcribe", "unknown_x"} {
for _, n := range []string{"shell", "bash", "write_file", "patch", "read_file", "list_dir", "search_files", "web_search", "browser", "http_request", "delegate_tasks", "memory", "vision", "transcribe", "unknown_x"} {
if toolGlyph(n) == "" {
t.Errorf("empty glyph for %q", n)
}
Expand Down
39 changes: 24 additions & 15 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
for j := range steps {
if steps[j].name == nm && !steps[j].done {
steps[j].done = true
steps[j].result = resultPreview(ev.Data)
steps[j].detailResult = boundedStructuredDetail(nm, ev.Data)
steps[j].isErr = looksLikeError(steps[j].result) || hasFailedExit(ev.Data) || structuredResultFailed(nm, ev.Data)
steps[j].result = toolResultPreview(nm, ev.Data)
steps[j].isErr = looksLikeError(steps[j].result) || hasFailedExit(ev.Data)
if steps[j].isErr && !steps[j].expanded {
// A failing step is why anyone expands anything —
// unfold it once so the diagnosis is on screen
Expand Down Expand Up @@ -745,10 +744,9 @@ func eventTail(ev client.Event) string {
// wrapper shows up as <untrusted_content_… — the envelope is
// decoded and the real content rendered, with remaining scalar metadata
// as a one-line footer.
// 2. parallel-tool envelopes (parallel_shell, delegate_tasks): a top-level
// "results" array is extracted item by item — each item renders only its
// display body (stdout, stderr, non-zero exit codes); command echoes,
// indexes, and durations stay hidden.
// 2. results arrays (including delegate_tasks): each item renders its display
// body, stderr, and non-zero exit code; command echoes and durations stay
// hidden. Retired tools bypass normalization in toolResultPreview.
// 3. untrusted_content wrappers, folded away entirely
// body renders. Both the literal tag form (live stream events) and the
// < escaped form (undecoded JSON envelopes) are recognized.
Expand Down Expand Up @@ -815,10 +813,9 @@ var resultsItemFields = []string{
}

// decodeResultsArray unwraps a parallel-tool envelope: a JSON object whose
// "results" field is an array of per-call objects (odek parallel_shell and
// delegate_tasks shapes). Each item renders as its display body plus stderr
// and non-zero exit-code lines; items are labelled [1], [2], … only when
// there are several. Metadata beside "results" is ignored — the items are
// "results" field is an array of per-call objects from delegate_tasks. Each
// item renders as its display body plus stderr and non-zero exit-code lines;
// items are labelled [1], [2], … only when there are several. Metadata beside "results" is ignored — the items are
// the payload. Any foreign shape — empty arrays, non-object items, items
// without a known display field — is returned unchanged: never lossy.
func decodeResultsArray(data string) string {
Expand Down Expand Up @@ -1276,11 +1273,23 @@ func argPreview(data string) string {
return truncate(collapse(strings.Join(parts, " ")), 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.
// toolResultPreview preserves retired results as generic data while supported
// tools keep normal envelope decoding. Every path sanitizes and bounds output.
func toolResultPreview(name, data string) string {
if retiredTool(name) {
return boundedResultPreview(foldUntrustedWrappers(data))
}
return resultPreview(data)
}

// resultPreview sanitizes normalized output and caps it so the transcript does
// not retain the unbounded output of a chatty tool.
func resultPreview(data string) string {
s := sanitize(normalizeToolResult(data))
return boundedResultPreview(normalizeToolResult(data))
}

func boundedResultPreview(data string) string {
s := sanitize(data)
const byteLimit = 128 * 1024
if len(s) > byteLimit {
cut := byteLimit
Expand Down
14 changes: 14 additions & 0 deletions internal/tui/icons.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const (
// feed reads at a glance. Matching is by substring to cover odek's native
// tools, MCP tools (server__tool), and sub-agent variants.
func toolGlyph(name string) string {
if retiredTool(name) {
return "✦"
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "shell"), strings.Contains(n, "bash"), strings.Contains(n, "exec"):
Expand Down Expand Up @@ -54,3 +57,14 @@ func resourceGlyph(typ string) string {
return "≡"
}
}

// retiredTool keeps historical tool names on the generic display path instead
// of matching the substring-based renderers for supported tools and MCP names.
func retiredTool(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "parallel_shell", "batch_patch", "batch_read", "multi_grep", "http_batch":
return true
default:
return false
}
}
6 changes: 3 additions & 3 deletions internal/tui/invocation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ func TestInvocationDisplaysUnsafeCharactersAndLimit(t *testing.T) {
t.Fatalf("invalid UTF-8 byte was hidden: %q", got)
}

raw := `{"commands":["first","second"],"note":"` + strings.Repeat("x", toolArgsLimit) + `"}`
raw := `{"tasks":["first","second"],"note":"` + strings.Repeat("x", toolArgsLimit) + `"}`
retained, omitted := retainToolArgs(raw)
if !omitted || len(retained) > toolArgsLimit {
t.Fatal("tool arguments were not bounded")
}
limited := invocationText(step{name: "parallel_shell", callArgs: retained, argsOmitted: omitted})
limited := invocationText(step{name: "delegate_tasks", callArgs: retained, argsOmitted: omitted})
if !strings.Contains(limited, "limited to 256 KiB") || !strings.Contains(limited, "remaining invocation arguments omitted") {
t.Fatal("bounded invocation has no visible limit marker")
}
Expand All @@ -154,7 +154,7 @@ func TestExpandedApprovalCommandWrapsWideCharacters(t *testing.T) {
}

func TestNestedInvocationAndCopyTarget(t *testing.T) {
s := step{name: "parallel_shell", callArgs: `{"commands":[{"command":"go test ./..."},{"command":"go vet ./..."}]}`}
s := step{name: "delegate_tasks", callArgs: `{"tasks":[{"prompt":"go test ./..."},{"prompt":"go vet ./..."}]}`}
got := invocationText(s)
for _, want := range []string{"go test ./...", "go vet ./..."} {
if !strings.Contains(got, want) {
Expand Down
1 change: 0 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ type step struct {
callArgs string // retained tool-call arguments for deliberate inspection
argsOmitted bool // callArgs exceeded the bounded inspection limit
result string // sanitized tool output (multi-line); excerpted at render
detailResult string // bounded structured display data; normalized result remains copyable
detailOffset int // first visible line in the expanded response
done bool
isErr bool // the result reads as a failure (tints the status glyph red)
Expand Down
9 changes: 9 additions & 0 deletions internal/tui/narrative.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ func scanReceipt(msg message) receipt {
files := map[string]struct{}{}
var r receipt
for _, s := range msg.steps {
if retiredTool(s.name) {
continue
}
if p := touchedPath(s.name, s.arg); p != "" {
files[p] = struct{}{}
}
Expand Down Expand Up @@ -235,6 +238,9 @@ func formatReceipt(r receipt) string {
// touchedPath is the file a write/patch/edit step named. Reads do not
// count — the receipt is what the turn changed, not what it looked at.
func touchedPath(name, arg string) string {
if retiredTool(name) {
return ""
}
n := strings.ToLower(name)
if !strings.Contains(n, "write") && !strings.Contains(n, "patch") && !strings.Contains(n, "edit") {
return ""
Expand All @@ -247,6 +253,9 @@ func touchedPath(name, arg string) string {
}

func isShellTool(name string) bool {
if retiredTool(name) {
return false
}
n := strings.ToLower(name)
return strings.Contains(n, "shell") || strings.Contains(n, "bash") || strings.Contains(n, "exec")
}
Expand Down
14 changes: 7 additions & 7 deletions internal/tui/normalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ func TestResultPreviewMalformedEnvelope(t *testing.T) {
}
}

// Parallel tools (parallel_shell) return a top-level results array of per-
// Delegation tools return a top-level results array of per-
// call objects. resultPreview extracts each item's display body — stdout,
// stderr, non-zero exit codes — and drops the JSON noise (command echoes,
// index, duration). Wrappers inside stdout fold away entirely.
func TestResultPreviewParallelResults(t *testing.T) {
raw := `{"results":[{"index":0,"command":"gofmt -l .","description":"check formatting","stdout":"\u003cuntrusted_content_abc123 source=\"parallel_shell:0:stdout\"\u003e\nREADME.md\n\u003c/untrusted_content_abc123\u003e","stderr":"","exit_code":0,"duration_ms":12},{"index":1,"command":"go vet ./...","description":"vet","stdout":"","stderr":"vets hate this","exit_code":1,"duration_ms":300}]}`
got := resultPreview(raw)
func TestDelegateResultPreviewResults(t *testing.T) {
raw := `{"results":[{"index":0,"command":"gofmt -l .","description":"check formatting","stdout":"\u003cuntrusted_content_abc123 source=\"delegate_tasks:0:stdout\"\u003e\nREADME.md\n\u003c/untrusted_content_abc123\u003e","stderr":"","exit_code":0,"duration_ms":12},{"index":1,"command":"go vet ./...","description":"vet","stdout":"","stderr":"vets hate this","exit_code":1,"duration_ms":300}]}`
got := toolResultPreview("delegate_tasks", raw)
for _, want := range []string{"[1] README.md", "[2] exit status 1", "stderr: vets hate this"} {
if !strings.Contains(got, want) {
t.Errorf("parallel results missing %q in:\n%s", want, got)
Expand All @@ -82,7 +82,7 @@ func TestResultPreviewParallelResults(t *testing.T) {
// A single-item results array renders the body bare — no index label.
func TestResultPreviewSingleResult(t *testing.T) {
raw := `{"results":[{"index":0,"command":"ls","stdout":"main.go\nutil.go","stderr":"","exit_code":0,"duration_ms":5}]}`
got := resultPreview(raw)
got := toolResultPreview("delegate_tasks", raw)
if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") {
t.Errorf("stdout body lost:\n%s", got)
}
Expand All @@ -94,7 +94,7 @@ func TestResultPreviewSingleResult(t *testing.T) {
// Non-stdout result shapes (delegate_tasks headlines) still extract.
func TestResultPreviewResultsHeadline(t *testing.T) {
raw := `{"results":[{"headline":"built 3 sub-agents","artifacts":[{"id":"a1","path":"x.go","bytes":10}],"cost_usd":0.5}]}`
got := resultPreview(raw)
got := toolResultPreview("delegate_tasks", raw)
if !strings.Contains(got, "built 3 sub-agents") {
t.Errorf("headline body lost:\n%s", got)
}
Expand Down Expand Up @@ -131,7 +131,7 @@ func TestResultPreviewResultsFailSafe(t *testing.T) {
`{"results":[{"weird":{"a":1}}]}`,
`{"results":[{"other":"only unknown scalars"}]}`,
} {
if got := resultPreview(s); got != s {
if got := toolResultPreview("delegate_tasks", s); got != s {
t.Errorf("resultPreview(%q) = %q, want unchanged", s, got)
}
}
Expand Down
5 changes: 2 additions & 3 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,6 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) {
// live tool_result events carry the raw output — strip it so both
// render identically. resultPreview sanitizes the unwrapped output.
rawResult := stripToolResultFrame(mm.Content)
result := resultPreview(rawResult)
// Match by tool_call_id first; fall back to the live tool_result
// behavior of scanning backwards by name for an unfinished step.
idx, ok := stepByCallID[mm.ToolCallID]
Expand All @@ -1193,10 +1192,10 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) {
if !ok {
continue
}
result := toolResultPreview(cur.steps[idx].name, rawResult)
cur.steps[idx].done = true
cur.steps[idx].result = result
cur.steps[idx].detailResult = boundedStructuredDetail(cur.steps[idx].name, rawResult)
cur.steps[idx].isErr = looksLikeError(result) || hasFailedExit(rawResult) || structuredResultFailed(cur.steps[idx].name, rawResult)
cur.steps[idx].isErr = looksLikeError(result) || hasFailedExit(rawResult)
}
}
flush()
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
// toolProgress returns a playful, context-aware status line for a running tool,
// derived from the tool name and its argument preview.
func toolProgress(name, arg string) string {
if retiredTool(name) {
return "🔧 running " + name
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "shell"), strings.Contains(n, "bash"), strings.Contains(n, "exec"):
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/realtime_tabs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ func TestSubagentStateKicksAgentsFetch(t *testing.T) {
// telemetry (tool/step, elapsed) — not just the REST snapshot's coarse data.
func TestAgentsRowsPreferLiveCard(t *testing.T) {
m := stateFixture(t)
// Live card: the agent is on step 3 running "multi_grep", 40s elapsed.
// Live card: the agent is on step 3 running "search_files", 40s elapsed.
m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0,
Phase: "active", Status: "running", Step: 3, Tool: "multi_grep"})
Phase: "active", Status: "running", Step: 3, Tool: "search_files"})
// REST row is stale: server snapshot still says step 1 / shell, 2s.
m.panel = panelAgents // handleMgmtMsg drops cross-tab results
m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{
Expand All @@ -45,7 +45,7 @@ func TestAgentsRowsPreferLiveCard(t *testing.T) {
t.Fatal("no rows rendered")
}
joined := strings.Join(rows, " ")
if !strings.Contains(joined, "multi_grep") {
if !strings.Contains(joined, "search_files") {
t.Errorf("row ignores the live card's tool: %q", joined)
}
if strings.Contains(joined, "shell") {
Expand Down
Loading
Loading