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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 18 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,18 @@ 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
finishes (after the typed chip when one exists), so every line still
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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) |
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"},
Expand All @@ -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"},
Expand Down
8 changes: 4 additions & 4 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -173,14 +174,16 @@ 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
// argument, not on the subagent_state frames.
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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/hints.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
6 changes: 3 additions & 3 deletions internal/tui/hints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down
114 changes: 114 additions & 0 deletions internal/tui/invocation.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading