Skip to content

Commit 5ad6c84

Browse files
committed
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.
1 parent 77b326c commit 5ad6c84

16 files changed

Lines changed: 366 additions & 32 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
346346
plus one pager row, shrinking with terminal height. Split embedded newlines
347347
before counting and clamp ANSI display widths. Clamp paging at both ends;
348348
invalidate per-step caches when selection, offset, height, or theme changes.
349+
- A tool step keeps its short header preview separate from the retained call
350+
arguments. Deliberate expansion shows the invocation before the result,
351+
including while the call runs; `alt+i` copies the display-safe invocation.
352+
Wrap invocation lines by display cells before paging. Escape control and
353+
invisible characters visibly, and label any argument retention limit.
354+
Replay must offer the same inspection path as live ingestion.
349355
- Keep normalized `step.result` for copying/error compatibility and bounded
350356
`step.detailResult` for structured display. Preserve sanitized command/path
351357
identity through live and history ingestion; never infer item boundaries from

‎README.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,18 @@ own front-end settings are separate; see [Configuration](#configuration).
175175
([glamour](https://github.com/charmbracelet/glamour)).
176176
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
177177
per tool and a static live mark (`▸`); result bodies wait behind `^E` or a
178-
click so a finished step reads as one calm line. The
178+
click so a finished step reads as one calm line. An opened step shows its
179+
invocation before its result, including while the tool is running. Tool
180+
arguments are retained up to 256 KiB, with an explicit omission marker if
181+
larger. The
179182
status line is the only spinner. Running steps speak the same progress
180183
copy as the status line (`🧪 running tests`) and tick their own elapsed
181184
clock — and the sealed duration stays on the head after the call
182185
finishes (after the typed chip when one exists), so every line still
183186
answers “how long did this take”. Two
184187
or more in-flight calls wrap in a parallel swarm band that
185188
shrinks as members finish and dissolves on the last leftover. Full
186-
output stays behind expand.
189+
retained output stays behind expand.
187190
- **Fluent by default** — gradient wordmark, smooth braille spinner, smart
188191
autoscroll that never yanks you while you read history, a
189192
scroll-position indicator, and a mouse wheel that always scrolls the
@@ -272,7 +275,7 @@ own front-end settings are separate; see [Configuration](#configuration).
272275
trims, tool execution times) stays silent. Info traces fade after 3s;
273276
errors, warnings, and disconnect notes autoclose after 10s.
274277
- **Just-in-time hints** — the first time a state appears (a held prompt,
275-
a sub-agent swarm, a multi-step turn), a one-time 💡 tip teaches its key
278+
a sub-agent swarm, a tool call), a one-time 💡 tip teaches its key
276279
and dwells 8s (5s longer than info traces), then stays silent for the
277280
run. Features surface the moment they matter; no keybinding table
278281
required.
@@ -400,12 +403,13 @@ own front-end settings are separate; see [Configuration](#configuration).
400403
| `@` | Attach a file (see below) |
401404
| `alt+↑` / `alt+↓` | Jump to the previous / next turn |
402405
| `alt+y` | Copy the **focused** surface — reply, expanded step, or open reasoning (falls back to the latest reply) |
406+
| `alt+i` (inspecting a tool) | Copy the displayed invocation, including any omission marker |
403407
| `alt+m` | Mark a copy span; the next `alt+y` yanks sanitized replies from the mark through the focus |
404408
| `alt+r` | Re-send the last prompt (`/retry`) |
405409
| `alt+f` | Search the transcript (`⏎`/`n` next match · `N` previous · a hit expands the hidden step or reasoning block) |
406410
| `^F` | Fold/unfold the most recent turn card (or click any turn head) |
407411
| `↑`/`↓` (inspecting) | Select the previous / next tool or reasoning item; `Enter` expands it, `Esc` returns to the composer |
408-
| `PgUp`/`PgDn` (inspecting a tool) | Previous / next response page; `Right` cycles sub-agent chips when present |
412+
| `PgUp`/`PgDn` (inspecting a tool) | Previous / next invocation or response page; `Right` cycles sub-agent chips when present |
409413
| `^X` | Stop the running turn from any panel or inspection state (`y` confirms); unrelated expanded items stay open |
410414
| `^R` | Browse & resume saved sessions |
411415
| `^O` | Switch the model |
@@ -416,7 +420,7 @@ own front-end settings are separate; see [Configuration](#configuration).
416420
| `s` / `x` | Save / skip a pending skill-suggestion chip while the composer is empty (`alt+s`/`alt+x` work everywhere) |
417421
| `1`–`3` | Resume a recent session from the home screen |
418422
| `^L` | Clear the conversation (two-step confirm: `y` clears, any other key cancels) |
419-
| `^E` | Toggle details — reasoning previews and every step's full output/logs (hidden in the calm default) |
423+
| `^E` | Toggle details — reasoning previews and every step's invocation and retained output/logs (hidden in the calm default) |
420424
| `^Y` | Copy the last reply to the clipboard (local helper — `pbcopy`/`wl-copy`/`clip` — with OSC 52 fallback) |
421425
| `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. |
422426
| `^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
434438
non-character keys (`^K` palette, `alt+↑↓` turn jumps, `F1` help), so a
435439
prompt can start with `?`, `[`, or any other character.
436440

437-
### Inspecting tool responses
441+
### Inspecting tool calls
438442

439443
Click a tool header or reasoning block to inspect it; while inspecting, `↑`/
440-
`↓` move between items and Enter expands the focused one. Tool responses
441-
display at most eight body rows plus a paging indicator, with fewer
442-
rows in short terminals. Use `PgUp`/`PgDn` to page, `alt+y` to copy the
443-
retained response, and Escape to return to typing. The global `^E`
444-
details toggle uses the same page limits.
444+
`↓` move between items and Enter expands the focused one. The invocation
445+
appears first, even while a tool is running; the result follows when it
446+
arrives. Long invocation lines wrap by display cells. Details display at
447+
most eight rows plus a paging indicator, with fewer rows in short terminals.
448+
Use `PgUp`/`PgDn` to page, `alt+i` to copy the displayed invocation,
449+
`alt+y` to copy the retained response, and Escape to return to typing.
450+
The global `^E` details toggle uses the same page limits. Control and
451+
invisible characters in invocations appear as safe escape text.
445452

446453
Batch results retain command/file labels and original item counts; bracketed
447454
log lines are never treated as extra commands. Plans render creation, updates,

‎internal/tui/cache.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ func stepBlockCacheValid(s step, st *step, m *Model, expanded bool) bool {
6464
if !s.done || s.blockCache == "" || st == nil {
6565
return false
6666
}
67-
if st.name != s.name || st.done != s.done || st.result != s.result || st.expanded != s.expanded {
67+
if st.name != s.name || st.done != s.done || st.result != s.result || st.expanded != s.expanded ||
68+
st.callArgs != s.callArgs || st.argsOmitted != s.argsOmitted {
6869
return false
6970
}
7071
if st.dur != s.dur {

‎internal/tui/clipboard.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,19 @@ func (m *Model) copyFocusedTurn() tea.Cmd {
227227
return m.copyText(m.focusedCopyText())
228228
}
229229

230+
// copyFocusedInvocation copies the display-safe arguments of the selected
231+
// tool call. It never copies an output body or a truncated header as a command.
232+
func (m *Model) copyFocusedInvocation() tea.Cmd {
233+
if !m.validInspect() || m.inspect.stepIdx < 0 {
234+
return m.transientNoteCmd("select a tool step to copy its invocation")
235+
}
236+
s := m.msgs[m.inspect.msgIdx].steps[m.inspect.stepIdx]
237+
if text := invocationText(s); text != "" {
238+
return m.copyText(text)
239+
}
240+
return m.transientNoteCmd("this step has no retained invocation")
241+
}
242+
230243
// focusedCopyText is the sanitized payload for the current inspect surface.
231244
func (m *Model) focusedCopyText() string {
232245
if m.validInspect() {

‎internal/tui/commands.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ func (m *Model) buildHelpCard() string {
310310
{"↑↓", "scroll the transcript"},
311311
{"alt+↑↓", "jump to the previous/next turn"},
312312
{"alt+y", "copy the focused surface (reply, step, or reasoning)"},
313+
{"alt+i", "copy the inspected tool invocation"},
313314
{"alt+m", "mark a copy span · alt+y yanks from the mark"},
314315
{"^Y", "copy the latest reply"},
315316
{"alt+r", "re-send the last prompt (/retry)"},
@@ -326,7 +327,7 @@ func (m *Model) buildHelpCard() string {
326327
{"^S", "stop the running sub-agent"},
327328
{"^X", "arm turn cancellation from any state (y confirms)"},
328329
{"^L", "clear the conversation"},
329-
{"^E", "toggle details (reasoning + tool output)"},
330+
{"^E", "toggle details (reasoning + tool invocation/result)"},
330331
{"alt+f", "find in the transcript"},
331332
{"esc", "close overlay · cancel the running turn (y confirms)"},
332333
{"/server", "cockpit — server, link, budget, session"},

‎internal/tui/events.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
163163

164164
case "tool_call":
165165
arg := argPreview(ev.Data)
166+
callArgs, argsOmitted := retainToolArgs(ev.Data)
166167
if ev.Name == "plan" {
167168
if s := planArgSummary(ev.Data); s != "" {
168169
arg = s // semantic one-liner replaces the JSON blob (docs §4A)
@@ -173,14 +174,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
173174
if i := m.cur(); i >= 0 {
174175
sealThinking(&m.msgs[i])
175176
m.msgs[i].steps = append(m.msgs[i].steps,
176-
step{name: nm, arg: arg, subagent: isSubagent(nm), started: time.Now()})
177+
step{name: nm, arg: arg, callArgs: callArgs, argsOmitted: argsOmitted,
178+
subagent: isSubagent(nm), started: time.Now()})
177179
last := len(m.msgs[i].steps) - 1
178180
if m.msgs[i].steps[last].subagent {
179181
// Per-task identity (goals, profiles) lives in the parent's
180182
// argument, not on the subagent_state frames.
181183
m.msgs[i].steps[last].manifest = parseDelegateManifest(ev.Data)
182184
}
183185
m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: last})
186+
m.teach(hintSteps, "tip: click a step to inspect its invocation and result · ^E expands all")
184187
}
185188
m.lastTool = nm
186189
m.lastArg = arg
@@ -288,9 +291,6 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
288291
m.msgs[i].stats = &ts
289292
m.turnStats = append(m.turnStats, ts)
290293
m.toolTotal += ts.toolCount
291-
if ts.toolCount > 0 {
292-
m.teach(hintSteps, "tip: click any step to expand its output · ^E expands all · /verbosity dials detail")
293-
}
294294
}
295295
m.renderPending = false // the turn's final state renders now, not on a flush
296296
m.finalize()

‎internal/tui/find.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func findMsgMatch(msg message, q string) bool {
121121
}
122122
}
123123
for _, s := range msg.steps {
124-
if strings.Contains(strings.ToLower(s.name+" "+s.arg+" "+s.result), q) {
124+
if strings.Contains(strings.ToLower(s.name+" "+s.arg+" "+s.callArgs+" "+s.result), q) {
125125
return true
126126
}
127127
for _, l := range s.logs {
@@ -151,7 +151,7 @@ func (m *Model) revealFindHit(msgIdx int) {
151151
}
152152
for i := range msg.steps {
153153
s := &msg.steps[i]
154-
blob := strings.ToLower(s.name + " " + s.arg + " " + s.result + " " + strings.Join(s.logs, " "))
154+
blob := strings.ToLower(s.name + " " + s.arg + " " + s.callArgs + " " + s.result + " " + strings.Join(s.logs, " "))
155155
if strings.Contains(blob, q) {
156156
s.expanded = true
157157
clearStepBlockCache(s)

‎internal/tui/hints.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import "time"
1717
const (
1818
hintQueue = "queue" // first prompt held while a turn runs
1919
hintSwarm = "swarm" // first sub-agent swarm frame on screen
20-
hintSteps = "steps" // first finished turn that carried tool steps
20+
hintSteps = "steps" // first tool call, while its invocation can be inspected live
2121
hintCtx = "ctx" // first real prompt: decode the header instruments
2222
)
2323

‎internal/tui/hints_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ func TestStepsHintFiresOnce(t *testing.T) {
9595
m := liveTurnModel()
9696
runMiniTurn(t, m, "read_file", "contents")
9797
m.handleEvent(client.Event{Type: "done"})
98-
if countNotices(m, "tip: click any step") != 1 {
99-
t.Fatalf("first stepped turn must teach expansion once, notices: %q", m.notices)
98+
if countNotices(m, "tip: click a step to inspect") != 1 {
99+
t.Fatalf("first tool call must teach inspection once, notices: %q", m.notices)
100100
}
101101
// A second stepped turn must not repeat the tip.
102102
m.busy = true
@@ -106,7 +106,7 @@ func TestStepsHintFiresOnce(t *testing.T) {
106106
m.curIdx = len(m.msgs) - 1
107107
runMiniTurn(t, m, "read_file", "more")
108108
m.handleEvent(client.Event{Type: "done"})
109-
if countNotices(m, "tip: click any step") != 1 {
109+
if countNotices(m, "tip: click a step to inspect") != 1 {
110110
t.Fatalf("hint repeated on the second stepped turn: %q", m.notices)
111111
}
112112
}

‎internal/tui/invocation.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package tui
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
"unicode/utf8"
8+
9+
"github.com/charmbracelet/x/ansi"
10+
)
11+
12+
// Tool arguments can contain file bodies and prompts. Keep inspection bounded,
13+
// and say so when the wire payload exceeds the limit.
14+
const toolArgsLimit = 256 * 1024
15+
16+
func retainToolArgs(raw string) (string, bool) {
17+
if len(raw) <= toolArgsLimit {
18+
return raw, false
19+
}
20+
cut := toolArgsLimit
21+
for cut > 0 && !utf8.RuneStart(raw[cut]) {
22+
cut--
23+
}
24+
return raw[:cut], true
25+
}
26+
27+
// visibleInvocation escapes terminal controls and invisible Unicode instead
28+
// of silently deleting them. The inspector must show when a command contains
29+
// bytes that cannot safely be sent to a terminal as display text.
30+
func visibleInvocation(raw string) string {
31+
var b strings.Builder
32+
for len(raw) > 0 {
33+
r, size := utf8.DecodeRuneInString(raw)
34+
if r == utf8.RuneError && size == 1 {
35+
_, _ = fmt.Fprintf(&b, "\\x%02X", raw[0])
36+
raw = raw[1:]
37+
continue
38+
}
39+
raw = raw[size:]
40+
switch {
41+
case r == '\n':
42+
b.WriteRune(r)
43+
case r == '\t':
44+
b.WriteString(`\t`)
45+
case r == '\r':
46+
b.WriteString(`\r`)
47+
case isControl(r):
48+
_, _ = fmt.Fprintf(&b, "\\x%02X", r)
49+
case isInvisible(r):
50+
_, _ = fmt.Fprintf(&b, "\\u%04X", r)
51+
default:
52+
b.WriteRune(r)
53+
}
54+
}
55+
return sanitize(b.String())
56+
}
57+
58+
// invocationText puts the command first for shell tools, followed by every
59+
// other argument. Other tools show the full JSON arguments. The short step
60+
// header remains separate; it must never stand in for this inspection text.
61+
func invocationText(s step) string {
62+
if s.callArgs == "" && !s.argsOmitted {
63+
return ""
64+
}
65+
raw := s.callArgs
66+
var fields map[string]json.RawMessage
67+
if json.Unmarshal([]byte(raw), &fields) == nil && fields != nil {
68+
if isShellTool(s.name) {
69+
for _, key := range []string{"command", "cmd"} {
70+
var command string
71+
if json.Unmarshal(fields[key], &command) != nil || command == "" {
72+
continue
73+
}
74+
delete(fields, key)
75+
out := "invocation · command\n" + visibleInvocation(command)
76+
if len(fields) > 0 {
77+
if rest, err := json.MarshalIndent(fields, "", " "); err == nil {
78+
out += "\nother arguments\n" + visibleInvocation(string(rest))
79+
}
80+
}
81+
return appendArgsLimit(out, s.argsOmitted)
82+
}
83+
}
84+
if pretty, err := json.MarshalIndent(fields, "", " "); err == nil {
85+
return appendArgsLimit("invocation · arguments\n"+visibleInvocation(string(pretty)), s.argsOmitted)
86+
}
87+
}
88+
return appendArgsLimit("invocation · arguments\n"+visibleInvocation(raw), s.argsOmitted)
89+
}
90+
91+
func appendArgsLimit(text string, omitted bool) string {
92+
if omitted {
93+
head, body, _ := strings.Cut(text, "\n")
94+
return head + " · limited to 256 KiB\n" + body +
95+
"\n… remaining invocation arguments omitted"
96+
}
97+
return text
98+
}
99+
100+
func invocationDetailLines(s step, width int, th theme) []string {
101+
text := invocationText(s)
102+
if text == "" {
103+
return nil
104+
}
105+
var out []string
106+
for i, line := range strings.Split(ansi.Hardwrap(text, max(1, width), true), "\n") {
107+
style := th.stepRes
108+
if i == 0 || line == "other arguments" || strings.HasPrefix(line, "… remaining invocation") {
109+
style = th.stepArg
110+
}
111+
out = append(out, style.Render(line))
112+
}
113+
return out
114+
}

0 commit comments

Comments
 (0)