detached 8927ac1

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.
commit
8927ac15dc914538eee1316d041fbc18132ec8d3
author
patriceckhart · · 2026-04-20 13:55 UTC
parent
bb50aa3
signature
unsigned
refs
tag: v0.0.67
2 files · +85 -42
M internal/agent/modes/interactive.go +2 -26
@@ -2,7 +2,6 @@ package modes
22
33 import (
44 "context"
5- "encoding/json"
65 "fmt"
76 "os"
87 "path/filepath"
@@ -2268,13 +2267,13 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
22682267 // refresh the final Args summary. Otherwise create a new one
22692268 // (non-streaming providers or legacy paths).
22702269 if tc, ok := i.toolCalls[e.ID]; ok {
2271- tc.Args = shortArgs(e.Args)
2270+ tc.Args = tui.ShortArgs(e.Name, e.Args)
22722271 tc.Streaming = false
22732272 } else {
22742273 i.toolCalls[e.ID] = &tui.ToolCallView{
22752274 ID: e.ID,
22762275 Name: e.Name,
2277- Args: shortArgs(e.Args),
2276+ Args: tui.ShortArgs(e.Name, e.Args),
22782277 }
22792278 i.toolOrder = append(i.toolOrder, e.ID)
22802279 }
@@ -2326,29 +2325,6 @@ func (i *Interactive) Agent() *core.Agent {
23262325 return i.agent
23272326 }
23282327
2329-func shortArgs(raw json.RawMessage) string {
2330- var v any
2331- if err := json.Unmarshal(raw, &v); err != nil {
2332- return ""
2333- }
2334- if m, ok := v.(map[string]any); ok {
2335- for _, k := range []string{"path", "file_path", "command"} {
2336- if s, ok := m[k].(string); ok {
2337- if len(s) > 60 {
2338- s = s[:57] + "..."
2339- }
2340- return s
2341- }
2342- }
2343- }
2344- b, _ := json.Marshal(v)
2345- s := string(b)
2346- if len(s) > 60 {
2347- s = s[:57] + "..."
2348- }
2349- return s
2350-}
2351-
23522328 // silence unused import in some build configs
23532329 var _ = fmt.Sprintf
23542330
@@ -2,7 +2,6 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/json"
6 "fmt"5 "fmt"
7 "os"6 "os"
8 "path/filepath"7 "path/filepath"
@@ -2268,13 +2267,13 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
2268 // refresh the final Args summary. Otherwise create a new one2267 // refresh the final Args summary. Otherwise create a new one
2269 // (non-streaming providers or legacy paths).2268 // (non-streaming providers or legacy paths).
2270 if tc, ok := i.toolCalls[e.ID]; ok {2269 if tc, ok := i.toolCalls[e.ID]; ok {
2271- tc.Args = shortArgs(e.Args)2270+ tc.Args = tui.ShortArgs(e.Name, e.Args)
2272 tc.Streaming = false2271 tc.Streaming = false
2273 } else {2272 } else {
2274 i.toolCalls[e.ID] = &tui.ToolCallView{2273 i.toolCalls[e.ID] = &tui.ToolCallView{
2275 ID: e.ID,2274 ID: e.ID,
2276 Name: e.Name,2275 Name: e.Name,
2277- Args: shortArgs(e.Args),2276+ Args: tui.ShortArgs(e.Name, e.Args),
2278 }2277 }
2279 i.toolOrder = append(i.toolOrder, e.ID)2278 i.toolOrder = append(i.toolOrder, e.ID)
2280 }2279 }
@@ -2326,29 +2325,6 @@ func (i *Interactive) Agent() *core.Agent {
2326 return i.agent2325 return i.agent
2327 }2326 }
2328 2327
2329-func shortArgs(raw json.RawMessage) string {
2330- var v any
2331- if err := json.Unmarshal(raw, &v); err != nil {
2332- return ""
2333- }
2334- if m, ok := v.(map[string]any); ok {
2335- for _, k := range []string{"path", "file_path", "command"} {
2336- if s, ok := m[k].(string); ok {
2337- if len(s) > 60 {
2338- s = s[:57] + "..."
2339- }
2340- return s
2341- }
2342- }
2343- }
2344- b, _ := json.Marshal(v)
2345- s := string(b)
2346- if len(s) > 60 {
2347- s = s[:57] + "..."
2348- }
2349- return s
2350-}
2351-
2352 // silence unused import in some build configs2328 // silence unused import in some build configs
2353 var _ = fmt.Sprintf2329 var _ = fmt.Sprintf
2354 2330
M internal/tui/view.go +83 -16
@@ -451,7 +451,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
451451 // assistant prose above. The matching closing rule
452452 // is emitted at the end of the tool-role message.
453453 lines = append(lines, toolBlockRule(v.Theme, width))
454- lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
454+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
455455 }
456456 }
457457 case provider.RoleTool:
@@ -1158,28 +1158,95 @@ func toolResultBlock(th Theme, text string, width int, color int) []string {
11581158 return out
11591159 }
11601160
1161-func shortArgs(raw json.RawMessage) string {
1161+// ShortArgs renders a tool call's arguments into a one-line
1162+// suffix for the "tool name <args>" header. tool is the tool
1163+// name so we can add shape-specific decorations: for read we
1164+// append the requested line range (e.g. "path:1-200") pulled
1165+// from the offset/limit args, which is useful context at a
1166+// glance without expanding the result body. Other tools keep
1167+// the legacy "path or command, truncated at 60 cells" shape.
1168+//
1169+// Exported because the interactive mode pre-populates the
1170+// ToolCallView.Args field with this value as soon as the tool
1171+// call is announced, so the live overlay's header matches what
1172+// the finalised transcript will later render.
1173+func ShortArgs(tool string, raw json.RawMessage) string {
11621174 var v any
11631175 if err := json.Unmarshal(raw, &v); err != nil {
11641176 return ""
11651177 }
1166- switch x := v.(type) {
1167- case map[string]any:
1168- for _, k := range []string{"path", "file_path", "command"} {
1169- if s, ok := x[k].(string); ok {
1170- if len(s) > 60 {
1171- s = s[:57] + "..."
1172- }
1173- return s
1174- }
1178+ x, ok := v.(map[string]any)
1179+ if !ok {
1180+ b, _ := json.Marshal(v)
1181+ s := string(b)
1182+ if len(s) > 60 {
1183+ s = s[:57] + "..."
1184+ }
1185+ return s
1186+ }
1187+ var primary string
1188+ for _, k := range []string{"path", "file_path", "command"} {
1189+ if s, ok := x[k].(string); ok {
1190+ primary = s
1191+ break
1192+ }
1193+ }
1194+ if primary == "" {
1195+ b, _ := json.Marshal(v)
1196+ s := string(b)
1197+ if len(s) > 60 {
1198+ s = s[:57] + "..."
1199+ }
1200+ return s
1201+ }
1202+
1203+ // Tool-specific decoration. Only the read tool gets a range
1204+ // suffix for now; other tools just truncate the primary arg.
1205+ suffix := ""
1206+ switch strings.ToLower(tool) {
1207+ case "read":
1208+ start := 1
1209+ if n, ok := toInt(x["offset"]); ok && n >= 1 {
1210+ start = n
1211+ }
1212+ if lim, ok := toInt(x["limit"]); ok && lim > 0 {
1213+ end := start + lim - 1
1214+ suffix = fmt.Sprintf(":%d-%d", start, end)
1215+ } else if start > 1 {
1216+ suffix = fmt.Sprintf(":%d-", start)
11751217 }
11761218 }
1177- b, _ := json.Marshal(v)
1178- s := string(b)
1179- if len(s) > 60 {
1180- s = s[:57] + "..."
1219+
1220+ // Truncate the primary arg leaving room for the suffix so the
1221+ // range stays visible even on absurdly long paths.
1222+ max := 60 - len(suffix)
1223+ if max < 10 {
1224+ max = 10
1225+ }
1226+ if len(primary) > max {
1227+ primary = primary[:max-3] + "..."
1228+ }
1229+ return primary + suffix
1230+}
1231+
1232+// toInt coerces a json.Unmarshal'd number (float64) or a string
1233+// containing a number into an int. Returns ok=false if the value
1234+// is neither. Used by shortArgs to survive model quirks where
1235+// numeric args come back as strings.
1236+func toInt(v any) (int, bool) {
1237+ switch n := v.(type) {
1238+ case float64:
1239+ return int(n), true
1240+ case int:
1241+ return n, true
1242+ case string:
1243+ i, err := strconv.Atoi(strings.TrimSpace(n))
1244+ if err != nil {
1245+ return 0, false
1246+ }
1247+ return i, true
11811248 }
1182- return s
1249+ return 0, false
11831250 }
11841251
11851252 func collectText(blocks []provider.Content) string {
@@ -451,7 +451,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
451 // assistant prose above. The matching closing rule451 // assistant prose above. The matching closing rule
452 // is emitted at the end of the tool-role message.452 // is emitted at the end of the tool-role message.
453 lines = append(lines, toolBlockRule(v.Theme, width))453 lines = append(lines, toolBlockRule(v.Theme, width))
454- lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))454+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
455 }455 }
456 }456 }
457 case provider.RoleTool:457 case provider.RoleTool:
@@ -1158,28 +1158,95 @@ func toolResultBlock(th Theme, text string, width int, color int) []string {
1158 return out1158 return out
1159 }1159 }
1160 1160
1161-func shortArgs(raw json.RawMessage) string {1161+// ShortArgs renders a tool call's arguments into a one-line
1162+// suffix for the "tool name <args>" header. tool is the tool
1163+// name so we can add shape-specific decorations: for read we
1164+// append the requested line range (e.g. "path:1-200") pulled
1165+// from the offset/limit args, which is useful context at a
1166+// glance without expanding the result body. Other tools keep
1167+// the legacy "path or command, truncated at 60 cells" shape.
1168+//
1169+// Exported because the interactive mode pre-populates the
1170+// ToolCallView.Args field with this value as soon as the tool
1171+// call is announced, so the live overlay's header matches what
1172+// the finalised transcript will later render.
1173+func ShortArgs(tool string, raw json.RawMessage) string {
1162 var v any1174 var v any
1163 if err := json.Unmarshal(raw, &v); err != nil {1175 if err := json.Unmarshal(raw, &v); err != nil {
1164 return ""1176 return ""
1165 }1177 }
1166- switch x := v.(type) {1178+ x, ok := v.(map[string]any)
1167- case map[string]any:1179+ if !ok {
1168- for _, k := range []string{"path", "file_path", "command"} {1180+ b, _ := json.Marshal(v)
1169- if s, ok := x[k].(string); ok {1181+ s := string(b)
1170- if len(s) > 60 {1182+ if len(s) > 60 {
1171- s = s[:57] + "..."1183+ s = s[:57] + "..."
1172- }1184+ }
1173- return s1185+ return s
1174- }1186+ }
1187+ var primary string
1188+ for _, k := range []string{"path", "file_path", "command"} {
1189+ if s, ok := x[k].(string); ok {
1190+ primary = s
1191+ break
1192+ }
1193+ }
1194+ if primary == "" {
1195+ b, _ := json.Marshal(v)
1196+ s := string(b)
1197+ if len(s) > 60 {
1198+ s = s[:57] + "..."
1199+ }
1200+ return s
1201+ }
1202+
1203+ // Tool-specific decoration. Only the read tool gets a range
1204+ // suffix for now; other tools just truncate the primary arg.
1205+ suffix := ""
1206+ switch strings.ToLower(tool) {
1207+ case "read":
1208+ start := 1
1209+ if n, ok := toInt(x["offset"]); ok && n >= 1 {
1210+ start = n
1211+ }
1212+ if lim, ok := toInt(x["limit"]); ok && lim > 0 {
1213+ end := start + lim - 1
1214+ suffix = fmt.Sprintf(":%d-%d", start, end)
1215+ } else if start > 1 {
1216+ suffix = fmt.Sprintf(":%d-", start)
1175 }1217 }
1176 }1218 }
1177- b, _ := json.Marshal(v)1219+
1178- s := string(b)1220+ // Truncate the primary arg leaving room for the suffix so the
1179- if len(s) > 60 {1221+ // range stays visible even on absurdly long paths.
1180- s = s[:57] + "..."1222+ max := 60 - len(suffix)
1223+ if max < 10 {
1224+ max = 10
1225+ }
1226+ if len(primary) > max {
1227+ primary = primary[:max-3] + "..."
1228+ }
1229+ return primary + suffix
1230+}
1231+
1232+// toInt coerces a json.Unmarshal'd number (float64) or a string
1233+// containing a number into an int. Returns ok=false if the value
1234+// is neither. Used by shortArgs to survive model quirks where
1235+// numeric args come back as strings.
1236+func toInt(v any) (int, bool) {
1237+ switch n := v.(type) {
1238+ case float64:
1239+ return int(n), true
1240+ case int:
1241+ return n, true
1242+ case string:
1243+ i, err := strconv.Atoi(strings.TrimSpace(n))
1244+ if err != nil {
1245+ return 0, false
1246+ }
1247+ return i, true
1181 }1248 }
1182- return s1249+ return 0, false
1183 }1250 }
1184 1251
1185 func collectText(blocks []provider.Content) string {1252 func collectText(blocks []provider.Content) string {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close