From 8927ac15dc914538eee1316d041fbc18132ec8d3 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 20 Apr 2026 15:55:34 +0200 Subject: [PATCH] feat(tui): show read's line range in the tool header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read calls now render their requested line range next to the path, so you can see at a glance what slice of the file the model looked at. before: ▸ read /Users/pat/Developer/zot/internal/tui/view.go after : ▸ read /Users/pat/Developer/zot/internal/tui/view.go:723-772 ▸ read /Users/pat/Developer/zot/internal/tui/view.go:100- ▸ read /Users/pat/Developer/zot/internal/tui/view.go The ":START-END" suffix appears when the call had a limit arg; the ":START-" (open-ended) form appears when only offset was supplied; no suffix appears for whole-file reads (the common case). Other tools (write, edit, bash) are unchanged - their args don't carry a range. Implementation: - shortArgs -> ShortArgs (exported), now takes the tool name as a first arg so it can add shape-specific decorations. For read, parses offset/limit from the args and appends the range; for everything else it falls back to the old path-or-command truncated-at-60 shape. - The truncation budget shrinks by the length of the suffix so absurdly long paths still leave the range visible (path gets the "..." in the middle, range stays intact at the tail). - toInt helper coerces float64 (json.Unmarshal's default), int, and numeric strings so we survive the occasional model that returns "100" instead of 100. - Dropped the duplicate unexported shortArgs in interactive.go (pre-dated the tui package's version). All call sites now go through tui.ShortArgs(name, args); the json import that only the local copy needed is gone too. No format string changes elsewhere; the extension intercept protocol, rpc wire schema, and session file format don't see the header string. --- internal/agent/modes/interactive.go | 28 +------- internal/tui/view.go | 99 ++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 64f5e67..af02dec 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -2,7 +2,6 @@ package modes import ( "context" - "encoding/json" "fmt" "os" "path/filepath" @@ -2268,13 +2267,13 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { // refresh the final Args summary. Otherwise create a new one // (non-streaming providers or legacy paths). if tc, ok := i.toolCalls[e.ID]; ok { - tc.Args = shortArgs(e.Args) + tc.Args = tui.ShortArgs(e.Name, e.Args) tc.Streaming = false } else { i.toolCalls[e.ID] = &tui.ToolCallView{ ID: e.ID, Name: e.Name, - Args: shortArgs(e.Args), + Args: tui.ShortArgs(e.Name, e.Args), } i.toolOrder = append(i.toolOrder, e.ID) } @@ -2326,29 +2325,6 @@ func (i *Interactive) Agent() *core.Agent { return i.agent } -func shortArgs(raw json.RawMessage) string { - var v any - if err := json.Unmarshal(raw, &v); err != nil { - return "" - } - if m, ok := v.(map[string]any); ok { - for _, k := range []string{"path", "file_path", "command"} { - if s, ok := m[k].(string); ok { - if len(s) > 60 { - s = s[:57] + "..." - } - return s - } - } - } - b, _ := json.Marshal(v) - s := string(b) - if len(s) > 60 { - s = s[:57] + "..." - } - return s -} - // silence unused import in some build configs var _ = fmt.Sprintf diff --git a/internal/tui/view.go b/internal/tui/view.go index d3a84f3..645f6b9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -451,7 +451,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string { // assistant prose above. The matching closing rule // is emitted at the end of the tool-role message. lines = append(lines, toolBlockRule(v.Theme, width)) - lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments))) + lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments))) } } case provider.RoleTool: @@ -1158,28 +1158,95 @@ func toolResultBlock(th Theme, text string, width int, color int) []string { return out } -func shortArgs(raw json.RawMessage) string { +// ShortArgs renders a tool call's arguments into a one-line +// suffix for the "tool name " header. tool is the tool +// name so we can add shape-specific decorations: for read we +// append the requested line range (e.g. "path:1-200") pulled +// from the offset/limit args, which is useful context at a +// glance without expanding the result body. Other tools keep +// the legacy "path or command, truncated at 60 cells" shape. +// +// Exported because the interactive mode pre-populates the +// ToolCallView.Args field with this value as soon as the tool +// call is announced, so the live overlay's header matches what +// the finalised transcript will later render. +func ShortArgs(tool string, raw json.RawMessage) string { var v any if err := json.Unmarshal(raw, &v); err != nil { return "" } - switch x := v.(type) { - case map[string]any: - for _, k := range []string{"path", "file_path", "command"} { - if s, ok := x[k].(string); ok { - if len(s) > 60 { - s = s[:57] + "..." - } - return s - } + x, ok := v.(map[string]any) + if !ok { + b, _ := json.Marshal(v) + s := string(b) + if len(s) > 60 { + s = s[:57] + "..." + } + return s + } + var primary string + for _, k := range []string{"path", "file_path", "command"} { + if s, ok := x[k].(string); ok { + primary = s + break } } - b, _ := json.Marshal(v) - s := string(b) - if len(s) > 60 { - s = s[:57] + "..." + if primary == "" { + b, _ := json.Marshal(v) + s := string(b) + if len(s) > 60 { + s = s[:57] + "..." + } + return s } - return s + + // Tool-specific decoration. Only the read tool gets a range + // suffix for now; other tools just truncate the primary arg. + suffix := "" + switch strings.ToLower(tool) { + case "read": + start := 1 + if n, ok := toInt(x["offset"]); ok && n >= 1 { + start = n + } + if lim, ok := toInt(x["limit"]); ok && lim > 0 { + end := start + lim - 1 + suffix = fmt.Sprintf(":%d-%d", start, end) + } else if start > 1 { + suffix = fmt.Sprintf(":%d-", start) + } + } + + // Truncate the primary arg leaving room for the suffix so the + // range stays visible even on absurdly long paths. + max := 60 - len(suffix) + if max < 10 { + max = 10 + } + if len(primary) > max { + primary = primary[:max-3] + "..." + } + return primary + suffix +} + +// toInt coerces a json.Unmarshal'd number (float64) or a string +// containing a number into an int. Returns ok=false if the value +// is neither. Used by shortArgs to survive model quirks where +// numeric args come back as strings. +func toInt(v any) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case string: + i, err := strconv.Atoi(strings.TrimSpace(n)) + if err != nil { + return 0, false + } + return i, true + } + return 0, false } func collectText(blocks []provider.Content) string {