From 5ad6c849cd8544fcdc9558bd27f77eeb9cb61f95 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 26 Sep 2026 09:41:02 +0200 Subject: [PATCH] fix(tui): show tool invocations in step details Retain bounded tool-call arguments for live and resumed turns so commands can be inspected while running and after completion. Correct the plain-mode approval keys and expose invocation copying. --- AGENTS.md | 6 ++ README.md | 29 +++--- internal/tui/cache.go | 3 +- internal/tui/clipboard.go | 13 +++ internal/tui/commands.go | 3 +- internal/tui/events.go | 8 +- internal/tui/find.go | 4 +- internal/tui/hints.go | 2 +- internal/tui/hints_test.go | 6 +- internal/tui/invocation.go | 114 +++++++++++++++++++++ internal/tui/invocation_test.go | 170 ++++++++++++++++++++++++++++++++ internal/tui/model.go | 4 + internal/tui/panels.go | 9 +- internal/tui/plain.go | 2 +- internal/tui/plain_test.go | 9 +- internal/tui/view.go | 16 ++- 16 files changed, 366 insertions(+), 32 deletions(-) create mode 100644 internal/tui/invocation.go create mode 100644 internal/tui/invocation_test.go diff --git a/AGENTS.md b/AGENTS.md index 6c49d40..c2ac93c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,6 +346,12 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` plus one pager row, shrinking with terminal height. Split embedded newlines before counting and clamp ANSI display widths. Clamp paging at both ends; invalidate per-step caches when selection, offset, height, or theme changes. +- A tool step keeps its short header preview separate from the retained call + arguments. Deliberate expansion shows the invocation before the result, + including while the call runs; `alt+i` copies the display-safe invocation. + Wrap invocation lines by display cells before paging. Escape control and + invisible characters visibly, and label any argument retention limit. + Replay must offer the same inspection path as live ingestion. - 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 diff --git a/README.md b/README.md index 42d639b..6cb13dd 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,10 @@ own front-end settings are separate; see [Configuration](#configuration). ([glamour](https://github.com/charmbracelet/glamour)). - **Tool activity** β€” every `tool_call`/`tool_result` shown live with a glyph per tool and a static live mark (`β–Έ`); result bodies wait behind `^E` or a - click so a finished step reads as one calm line. The + click so a finished step reads as one calm line. An opened step shows its + invocation before its result, including while the tool is running. Tool + arguments are retained up to 256 KiB, with an explicit omission marker if + larger. The status line is the only spinner. Running steps speak the same progress copy as the status line (`πŸ§ͺ running tests`) and tick their own elapsed clock β€” and the sealed duration stays on the head after the call @@ -183,7 +186,7 @@ own front-end settings are separate; see [Configuration](#configuration). answers β€œhow long did this take”. Two or more in-flight calls wrap in a parallel swarm band that shrinks as members finish and dissolves on the last leftover. Full - output stays behind expand. + retained output stays behind expand. - **Fluent by default** β€” gradient wordmark, smooth braille spinner, smart autoscroll that never yanks you while you read history, a scroll-position indicator, and a mouse wheel that always scrolls the @@ -272,7 +275,7 @@ own front-end settings are separate; see [Configuration](#configuration). trims, tool execution times) stays silent. Info traces fade after 3s; errors, warnings, and disconnect notes autoclose after 10s. - **Just-in-time hints** β€” the first time a state appears (a held prompt, - a sub-agent swarm, a multi-step turn), a one-time πŸ’‘ tip teaches its key + a sub-agent swarm, a tool call), a one-time πŸ’‘ tip teaches its key and dwells 8s (5s longer than info traces), then stays silent for the run. Features surface the moment they matter; no keybinding table required. @@ -400,12 +403,13 @@ own front-end settings are separate; see [Configuration](#configuration). | `@` | Attach a file (see below) | | `alt+↑` / `alt+↓` | Jump to the previous / next turn | | `alt+y` | Copy the **focused** surface β€” reply, expanded step, or open reasoning (falls back to the latest reply) | +| `alt+i` (inspecting a tool) | Copy the displayed invocation, including any omission marker | | `alt+m` | Mark a copy span; the next `alt+y` yanks sanitized replies from the mark through the focus | | `alt+r` | Re-send the last prompt (`/retry`) | | `alt+f` | Search the transcript (`⏎`/`n` next match Β· `N` previous Β· a hit expands the hidden step or reasoning block) | | `^F` | Fold/unfold the most recent turn card (or click any turn head) | | `↑`/`↓` (inspecting) | Select the previous / next tool or reasoning item; `Enter` expands it, `Esc` returns to the composer | -| `PgUp`/`PgDn` (inspecting a tool) | Previous / next response page; `Right` cycles sub-agent chips when present | +| `PgUp`/`PgDn` (inspecting a tool) | Previous / next invocation or response page; `Right` cycles sub-agent chips when present | | `^X` | Stop the running turn from any panel or inspection state (`y` confirms); unrelated expanded items stay open | | `^R` | Browse & resume saved sessions | | `^O` | Switch the model | @@ -416,7 +420,7 @@ own front-end settings are separate; see [Configuration](#configuration). | `s` / `x` | Save / skip a pending skill-suggestion chip while the composer is empty (`alt+s`/`alt+x` work everywhere) | | `1`–`3` | Resume a recent session from the home screen | | `^L` | Clear the conversation (two-step confirm: `y` clears, any other key cancels) | -| `^E` | Toggle details β€” reasoning previews and every step's full output/logs (hidden in the calm default) | +| `^E` | Toggle details β€” reasoning previews and every step's invocation and retained output/logs (hidden in the calm default) | | `^Y` | Copy the last reply to the clipboard (local helper β€” `pbcopy`/`wl-copy`/`clip` β€” with OSC 52 fallback) | | `Esc` | Close the topmost window or leave item inspection. Bare composer: dismiss details, then arm cancellation (`y` confirms). Approvals: fold details or leave confirmation editing; use `Alt+D` to deny. | | `^U` | Clear the whole input draft (`β‡§βŒ¦`/Shift+Delete works too on enhanced-key terminals β€” kitty CSI-u, modifyOtherKeys; elsewhere it degrades to plain single-char Delete) | @@ -434,14 +438,17 @@ when an approval arrives. No bare letter, digit, or punctuation key is bound in non-character keys (`^K` palette, `alt+↑↓` turn jumps, `F1` help), so a prompt can start with `?`, `[`, or any other character. -### Inspecting tool responses +### Inspecting tool calls Click a tool header or reasoning block to inspect it; while inspecting, `↑`/ -`↓` move between items and Enter expands the focused one. Tool responses -display at most eight body rows plus a paging indicator, with fewer -rows in short terminals. Use `PgUp`/`PgDn` to page, `alt+y` to copy the -retained response, and Escape to return to typing. The global `^E` -details toggle uses the same page limits. +`↓` move between items and Enter expands the focused one. The invocation +appears first, even while a tool is running; the result follows when it +arrives. Long invocation lines wrap by display cells. Details display at +most eight rows plus a paging indicator, with fewer rows in short terminals. +Use `PgUp`/`PgDn` to page, `alt+i` to copy the displayed invocation, +`alt+y` to copy the retained response, and Escape to return to typing. +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, diff --git a/internal/tui/cache.go b/internal/tui/cache.go index e73c5a4..73fc961 100644 --- a/internal/tui/cache.go +++ b/internal/tui/cache.go @@ -64,7 +64,8 @@ func stepBlockCacheValid(s step, st *step, m *Model, expanded bool) bool { if !s.done || s.blockCache == "" || st == nil { return false } - if st.name != s.name || st.done != s.done || st.result != s.result || st.expanded != s.expanded { + if st.name != s.name || st.done != s.done || st.result != s.result || st.expanded != s.expanded || + st.callArgs != s.callArgs || st.argsOmitted != s.argsOmitted { return false } if st.dur != s.dur { diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index 8de1e4b..7dd8fce 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -227,6 +227,19 @@ func (m *Model) copyFocusedTurn() tea.Cmd { return m.copyText(m.focusedCopyText()) } +// copyFocusedInvocation copies the display-safe arguments of the selected +// tool call. It never copies an output body or a truncated header as a command. +func (m *Model) copyFocusedInvocation() tea.Cmd { + if !m.validInspect() || m.inspect.stepIdx < 0 { + return m.transientNoteCmd("select a tool step to copy its invocation") + } + s := m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx] + if text := invocationText(s); text != "" { + return m.copyText(text) + } + return m.transientNoteCmd("this step has no retained invocation") +} + // focusedCopyText is the sanitized payload for the current inspect surface. func (m *Model) focusedCopyText() string { if m.validInspect() { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 12791e6..ec7da16 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -310,6 +310,7 @@ func (m *Model) buildHelpCard() string { {"↑↓", "scroll the transcript"}, {"alt+↑↓", "jump to the previous/next turn"}, {"alt+y", "copy the focused surface (reply, step, or reasoning)"}, + {"alt+i", "copy the inspected tool invocation"}, {"alt+m", "mark a copy span Β· alt+y yanks from the mark"}, {"^Y", "copy the latest reply"}, {"alt+r", "re-send the last prompt (/retry)"}, @@ -326,7 +327,7 @@ func (m *Model) buildHelpCard() string { {"^S", "stop the running sub-agent"}, {"^X", "arm turn cancellation from any state (y confirms)"}, {"^L", "clear the conversation"}, - {"^E", "toggle details (reasoning + tool output)"}, + {"^E", "toggle details (reasoning + tool invocation/result)"}, {"alt+f", "find in the transcript"}, {"esc", "close overlay Β· cancel the running turn (y confirms)"}, {"/server", "cockpit β€” server, link, budget, session"}, diff --git a/internal/tui/events.go b/internal/tui/events.go index f4f1285..1a0ee35 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -163,6 +163,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "tool_call": arg := argPreview(ev.Data) + callArgs, argsOmitted := retainToolArgs(ev.Data) if ev.Name == "plan" { if s := planArgSummary(ev.Data); s != "" { arg = s // semantic one-liner replaces the JSON blob (docs Β§4A) @@ -173,7 +174,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if i := m.cur(); i >= 0 { sealThinking(&m.msgs[i]) m.msgs[i].steps = append(m.msgs[i].steps, - step{name: nm, arg: arg, subagent: isSubagent(nm), started: time.Now()}) + step{name: nm, arg: arg, callArgs: callArgs, argsOmitted: argsOmitted, + subagent: isSubagent(nm), started: time.Now()}) last := len(m.msgs[i].steps) - 1 if m.msgs[i].steps[last].subagent { // Per-task identity (goals, profiles) lives in the parent's @@ -181,6 +183,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.msgs[i].steps[last].manifest = parseDelegateManifest(ev.Data) } m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: last}) + m.teach(hintSteps, "tip: click a step to inspect its invocation and result Β· ^E expands all") } m.lastTool = nm m.lastArg = arg @@ -288,9 +291,6 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.msgs[i].stats = &ts m.turnStats = append(m.turnStats, ts) m.toolTotal += ts.toolCount - if ts.toolCount > 0 { - m.teach(hintSteps, "tip: click any step to expand its output Β· ^E expands all Β· /verbosity dials detail") - } } m.renderPending = false // the turn's final state renders now, not on a flush m.finalize() diff --git a/internal/tui/find.go b/internal/tui/find.go index 14da3ee..9aa572e 100644 --- a/internal/tui/find.go +++ b/internal/tui/find.go @@ -121,7 +121,7 @@ func findMsgMatch(msg message, q string) bool { } } for _, s := range msg.steps { - if strings.Contains(strings.ToLower(s.name+" "+s.arg+" "+s.result), q) { + if strings.Contains(strings.ToLower(s.name+" "+s.arg+" "+s.callArgs+" "+s.result), q) { return true } for _, l := range s.logs { @@ -151,7 +151,7 @@ func (m *Model) revealFindHit(msgIdx int) { } for i := range msg.steps { s := &msg.steps[i] - blob := strings.ToLower(s.name + " " + s.arg + " " + s.result + " " + strings.Join(s.logs, " ")) + blob := strings.ToLower(s.name + " " + s.arg + " " + s.callArgs + " " + s.result + " " + strings.Join(s.logs, " ")) if strings.Contains(blob, q) { s.expanded = true clearStepBlockCache(s) diff --git a/internal/tui/hints.go b/internal/tui/hints.go index 96546ab..714bc3c 100644 --- a/internal/tui/hints.go +++ b/internal/tui/hints.go @@ -17,7 +17,7 @@ import "time" const ( hintQueue = "queue" // first prompt held while a turn runs hintSwarm = "swarm" // first sub-agent swarm frame on screen - hintSteps = "steps" // first finished turn that carried tool steps + hintSteps = "steps" // first tool call, while its invocation can be inspected live hintCtx = "ctx" // first real prompt: decode the header instruments ) diff --git a/internal/tui/hints_test.go b/internal/tui/hints_test.go index 4e4a830..fd95651 100644 --- a/internal/tui/hints_test.go +++ b/internal/tui/hints_test.go @@ -95,8 +95,8 @@ func TestStepsHintFiresOnce(t *testing.T) { m := liveTurnModel() runMiniTurn(t, m, "read_file", "contents") m.handleEvent(client.Event{Type: "done"}) - if countNotices(m, "tip: click any step") != 1 { - t.Fatalf("first stepped turn must teach expansion once, notices: %q", m.notices) + if countNotices(m, "tip: click a step to inspect") != 1 { + t.Fatalf("first tool call must teach inspection once, notices: %q", m.notices) } // A second stepped turn must not repeat the tip. m.busy = true @@ -106,7 +106,7 @@ func TestStepsHintFiresOnce(t *testing.T) { m.curIdx = len(m.msgs) - 1 runMiniTurn(t, m, "read_file", "more") m.handleEvent(client.Event{Type: "done"}) - if countNotices(m, "tip: click any step") != 1 { + if countNotices(m, "tip: click a step to inspect") != 1 { t.Fatalf("hint repeated on the second stepped turn: %q", m.notices) } } diff --git a/internal/tui/invocation.go b/internal/tui/invocation.go new file mode 100644 index 0000000..1f006a3 --- /dev/null +++ b/internal/tui/invocation.go @@ -0,0 +1,114 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// Tool arguments can contain file bodies and prompts. Keep inspection bounded, +// and say so when the wire payload exceeds the limit. +const toolArgsLimit = 256 * 1024 + +func retainToolArgs(raw string) (string, bool) { + if len(raw) <= toolArgsLimit { + return raw, false + } + cut := toolArgsLimit + for cut > 0 && !utf8.RuneStart(raw[cut]) { + cut-- + } + return raw[:cut], true +} + +// visibleInvocation escapes terminal controls and invisible Unicode instead +// of silently deleting them. The inspector must show when a command contains +// bytes that cannot safely be sent to a terminal as display text. +func visibleInvocation(raw string) string { + var b strings.Builder + for len(raw) > 0 { + r, size := utf8.DecodeRuneInString(raw) + if r == utf8.RuneError && size == 1 { + _, _ = fmt.Fprintf(&b, "\\x%02X", raw[0]) + raw = raw[1:] + continue + } + raw = raw[size:] + switch { + case r == '\n': + b.WriteRune(r) + case r == '\t': + b.WriteString(`\t`) + case r == '\r': + b.WriteString(`\r`) + case isControl(r): + _, _ = fmt.Fprintf(&b, "\\x%02X", r) + case isInvisible(r): + _, _ = fmt.Fprintf(&b, "\\u%04X", r) + default: + b.WriteRune(r) + } + } + return sanitize(b.String()) +} + +// invocationText puts the command first for shell tools, followed by every +// other argument. Other tools show the full JSON arguments. The short step +// header remains separate; it must never stand in for this inspection text. +func invocationText(s step) string { + if s.callArgs == "" && !s.argsOmitted { + return "" + } + raw := s.callArgs + var fields map[string]json.RawMessage + if json.Unmarshal([]byte(raw), &fields) == nil && fields != nil { + if isShellTool(s.name) { + for _, key := range []string{"command", "cmd"} { + var command string + if json.Unmarshal(fields[key], &command) != nil || command == "" { + continue + } + delete(fields, key) + out := "invocation Β· command\n" + visibleInvocation(command) + if len(fields) > 0 { + if rest, err := json.MarshalIndent(fields, "", " "); err == nil { + out += "\nother arguments\n" + visibleInvocation(string(rest)) + } + } + return appendArgsLimit(out, s.argsOmitted) + } + } + if pretty, err := json.MarshalIndent(fields, "", " "); err == nil { + return appendArgsLimit("invocation Β· arguments\n"+visibleInvocation(string(pretty)), s.argsOmitted) + } + } + return appendArgsLimit("invocation Β· arguments\n"+visibleInvocation(raw), s.argsOmitted) +} + +func appendArgsLimit(text string, omitted bool) string { + if omitted { + head, body, _ := strings.Cut(text, "\n") + return head + " Β· limited to 256 KiB\n" + body + + "\n… remaining invocation arguments omitted" + } + return text +} + +func invocationDetailLines(s step, width int, th theme) []string { + text := invocationText(s) + if text == "" { + return nil + } + var out []string + for i, line := range strings.Split(ansi.Hardwrap(text, max(1, width), true), "\n") { + style := th.stepRes + if i == 0 || line == "other arguments" || strings.HasPrefix(line, "… remaining invocation") { + style = th.stepArg + } + out = append(out, style.Render(line)) + } + return out +} diff --git a/internal/tui/invocation_test.go b/internal/tui/invocation_test.go new file mode 100644 index 0000000..0448223 --- /dev/null +++ b/internal/tui/invocation_test.go @@ -0,0 +1,170 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestToolInvocationSurvivesLiveAndReplay(t *testing.T) { + command := "printf 'start'\n" + strings.Repeat("echo x", 125) + " important-tail" + data, err := json.Marshal(map[string]string{"command": command, "workdir": "/tmp/work"}) + if err != nil { + t.Fatal(err) + } + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: string(data)}) + s := m.msgs[0].steps[0] + if s.callArgs != string(data) || s.argsOmitted { + t.Fatal("live tool call did not retain its arguments") + } + if !m.hintsShown[hintSteps] { + t.Fatal("invocation hint must appear while the call is running") + } + for _, want := range []string{"invocation Β· command", "important-tail", "workdir", "/tmp/work"} { + if !strings.Contains(invocationText(s), want) { + t.Errorf("live invocation missing %q", want) + } + } + + call := client.SessionToolCall{ID: "one"} + call.Function.Name = "shell" + call.Function.Arguments = string(data) + replay := newTestModel() + replay.replayTranscript([]client.SessionMessage{ + {Role: "user", Content: "run it"}, + {Role: "assistant", ToolCalls: []client.SessionToolCall{call}}, + }) + if got := replay.msgs[1].steps[0].callArgs; got != string(data) { + t.Fatalf("replayed invocation = %q", got) + } + if !strings.Contains(invocationText(replay.msgs[1].steps[0]), "important-tail") { + t.Fatal("replayed command tail is inaccessible") + } +} + +func TestExpandedStepShowsInvocationBeforeResult(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"command":"printf 'audit me' && echo hidden-tail"}`}) + if strings.Contains(plain(m.conversation()), "hidden-tail") { + t.Fatal("collapsed tool exposed its command tail") + } + m.toggleStep(0, 0) + if out := plain(m.conversation()); !strings.Contains(out, "hidden-tail") { + t.Fatalf("running tool has no expanded invocation: %q", out) + } + m.handleEvent(client.Event{Type: "tool_result", Name: "shell", Data: "tool-output"}) + out := plain(m.conversation()) + commandAt, resultAt := strings.Index(out, "hidden-tail"), strings.Index(out, "tool-output") + if commandAt < 0 || resultAt < 0 || commandAt >= resultAt { + t.Fatalf("expanded invocation must precede result: %q", out) + } +} + +func TestInvocationWrapsAndPagesWithoutLosingTail(t *testing.T) { + m := newTestModel() + command := strings.Repeat("long-token", 70) + " important-tail" + data, err := json.Marshal(map[string]string{"command": command}) + if err != nil { + t.Fatal(err) + } + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: string(data)}) + m.msgs[0].steps[0].expanded = true + m.inspect = &inspectTarget{msgIdx: 0, stepIdx: 0, itemIdx: -1} + width := max(m.vp.Width-8, 4) + for _, line := range invocationDetailLines(m.msgs[0].steps[0], width, m.th) { + if lipgloss.Width(line) > width { + t.Fatalf("invocation line is wider than the page: %d > %d", lipgloss.Width(line), width) + } + } + if strings.Contains(plain(m.conversation()), "important-tail") { + t.Fatal("command tail should need another page in this fixture") + } + found := false + for i := 0; i < 30; i++ { + m.Update(key("pgdown")) + if strings.Contains(plain(m.conversation()), "important-tail") { + found = true + break + } + } + if !found { + t.Fatal("paging never reached the command tail") + } +} + +func TestInvocationDisplaysUnsafeCharactersAndLimit(t *testing.T) { + data, err := json.Marshal(map[string]string{ + "command": "echo safe\x1b]52;c;secret\x07\u202e", + }) + if err != nil { + t.Fatal(err) + } + s := step{name: "shell", callArgs: string(data)} + got := invocationText(s) + if strings.ContainsAny(got, "\x1b\x07") || strings.ContainsRune(got, '\u202e') { + t.Fatalf("unsafe control reached invocation display: %q", got) + } + for _, want := range []string{`\x1B`, `\x07`, `\u202E`} { + if !strings.Contains(got, want) { + t.Errorf("hidden character %q was not represented: %q", want, got) + } + } + if got := visibleInvocation(string([]byte{0xff, 'x'})); got != `\xFFx` { + t.Fatalf("invalid UTF-8 byte was hidden: %q", got) + } + + raw := `{"commands":["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}) + if !strings.Contains(limited, "limited to 256 KiB") || !strings.Contains(limited, "remaining invocation arguments omitted") { + t.Fatal("bounded invocation has no visible limit marker") + } +} + +func TestExpandedApprovalCommandWrapsWideCharacters(t *testing.T) { + m := newTestModel() + m.approvals = []client.Event{{Type: "approval_request", Command: strings.Repeat("η•Œ", 45) + " command-tail"}} + m.apprExpanded = true + out := plain(m.approvalBody()) + if !strings.Contains(strings.ReplaceAll(out, "\n", ""), "command-tail") { + t.Fatalf("approval command tail was clipped: %q", out) + } + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > m.cardInner() { + t.Fatalf("approval line exceeds display width: %d > %d", lipgloss.Width(line), m.cardInner()) + } + } +} + +func TestNestedInvocationAndCopyTarget(t *testing.T) { + s := step{name: "parallel_shell", callArgs: `{"commands":[{"command":"go test ./..."},{"command":"go vet ./..."}]}`} + got := invocationText(s) + for _, want := range []string{"go test ./...", "go vet ./..."} { + if !strings.Contains(got, want) { + t.Errorf("nested invocation missing %q", want) + } + } + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{s}}) + m.inspect = &inspectTarget{msgIdx: 0, stepIdx: 0, itemIdx: -1} + if m.copyFocusedInvocation() == nil || !m.copyFlashing() { + t.Fatal("selected invocation was not offered to the clipboard") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index b1630d0..eae1cb5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -32,6 +32,8 @@ const ( type step struct { name string arg string + 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 @@ -1134,6 +1136,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Copy the focused surface β€” the one alt+↑/↓ last jumped to (falls // back to the latest reply). After alt+m this yanks the span. return m, m.copyFocusedTurn() + case "alt+i": + return m, m.copyFocusedInvocation() case "alt+m": return m, m.markCopySpan() case "alt+r": diff --git a/internal/tui/panels.go b/internal/tui/panels.go index a064545..27a91bd 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -1155,10 +1155,13 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { } for _, tc := range mm.ToolCalls { name := collapse(tc.Function.Name) + callArgs, argsOmitted := retainToolArgs(tc.Function.Arguments) cur.steps = append(cur.steps, step{ - name: name, - arg: argPreview(tc.Function.Arguments), - subagent: isSubagent(name), + name: name, + arg: argPreview(tc.Function.Arguments), + callArgs: callArgs, + argsOmitted: argsOmitted, + subagent: isSubagent(name), }) stepByCallID[tc.ID] = len(cur.steps) - 1 cur.items = append(cur.items, turnItem{stepIdx: len(cur.steps) - 1}) diff --git a/internal/tui/plain.go b/internal/tui/plain.go index 65f6df3..37cdc7f 100644 --- a/internal/tui/plain.go +++ b/internal/tui/plain.go @@ -74,7 +74,7 @@ func (m *Model) plainEventLines(ev client.Event) []string { if what != "" { what = " Β· " + what } - return []string{plainClip("⚠ approval" + what + " β€” ↑/↓ then ⏎ (Esc denies)")} + return []string{plainClip("⚠ approval β€” a approve Β· d deny (empty draft); Alt+A/Alt+D always" + what)} case "skill_event": return []string{"Β· skill Β· " + strings.TrimSpace(collapse(ev.SubType+" "+ev.SkillName)) + eventTail(ev)} diff --git a/internal/tui/plain_test.go b/internal/tui/plain_test.go index e75e0d6..e16e2d8 100644 --- a/internal/tui/plain_test.go +++ b/internal/tui/plain_test.go @@ -57,11 +57,18 @@ func TestPlainEventLinesApproval(t *testing.T) { if len(got) != 1 { t.Fatalf("got %d lines, want 1", len(got)) } - for _, want := range []string{"⚠ approval", "shell_exec", "rm -rf x", "Esc"} { + for _, want := range []string{"⚠ approval", "shell_exec", "rm -rf x", "Alt+D"} { if !strings.Contains(got[0], want) { t.Errorf("approval line %q missing %q", got[0], want) } } + if strings.Contains(got[0], "Esc denies") || strings.Contains(got[0], "↑/↓ then ⏎") { + t.Fatalf("plain approval still gives incorrect decision keys: %q", got[0]) + } + long := m.plainEventLines(client.Event{Type: "approval_request", Command: strings.Repeat("x", 200)}) + if len(long) != 1 || !strings.Contains(long[0], "Alt+D") { + t.Fatalf("long command hid approval controls: %v", long) + } } func TestPlainEventLinesSuppressed(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index fd5d23b..573c281 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1166,7 +1166,7 @@ func replyRefAt(msgIdx, start, n int) stepRef { } // renderStep renders one tool step: live progress + clock while it runs, -// a typed peek under a finished head, and the full inspect tree on expand. +// and the invocation plus retained result on expand. // Sub-agent steps always paint a chip strip; a focused chip opens the // mini-card. Returns the block, hit refs (head + chips), and line count. func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine int) (string, []stepRef, int) { @@ -1292,6 +1292,11 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in details = append(details, th.stepArg.Render(truncate(pendingChipLine(focus, s.manifest[focus]), detailBudget))) } } + if showAll { + // Keep the focused agent and the result card first. The parent + // invocation remains available on later pages of the full tree. + details = append(details, invocationDetailLines(s, detailBudget, th)...) + } for i, d := range m.toolDetailPage(&s, details, detailBudget) { conn := " " if i == 0 { @@ -1301,7 +1306,10 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in lipgloss.NewStyle().MaxWidth(detailBudget).Render(d)) } } else if expanded { - var details []string + details := invocationDetailLines(s, detailBudget, th) + if len(details) > 0 && s.done { + details = append(details, th.stepArg.Render("result")) + } if s.resultCard != nil { details = append(details, agentResultLines(m, s.resultCard, detailBudget)...) } else { @@ -1604,9 +1612,9 @@ func (m *Model) approvalBody() string { lines := []string{ansi.Truncate(head, budget, "…")} var body []string if m.apprExpanded { - body = append(body, wrapText(sanitize(target), budget)...) + body = append(body, strings.Split(ansi.Hardwrap(sanitize(target), budget, true), "\n")...) if a.Description != "" { - body = append(body, wrapText(sanitize(a.Description), budget)...) + body = append(body, strings.Split(ansi.Hardwrap(sanitize(a.Description), budget, true), "\n")...) } } else { body = append(body, truncate(collapse(target), budget))