feat(tui): show read's line range in the tool header

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.
This commit is contained in:
patriceckhart 2026-04-20 15:55:34 +02:00
parent bb50aa3044
commit 8927ac15dc
2 changed files with 85 additions and 42 deletions

View file

@ -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

View file

@ -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 <args>" 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 {