tui: make tool-call arg width configurable via ZOT_TOOL_ARG_WIDTH (#70)
The tool-call header truncates the primary argument (path/command/query) at a hardcoded 60 cells, which clips long queries on wide terminals. Add a toolArgWidth() helper (mirroring CellAspectRatio) that reads the ZOT_TOOL_ARG_WIDTH env var, defaulting to 60 and clamping to [20, 500] so a stray value can never produce an unreadable header or an out-of-range slice. Replace the three hardcoded 60s in ShortArgs. Documented under Tool rendering in the README; covered by tests.
- commit
- 88a6f6e481904a71b78983e198b8351ed8fd4790
- author
- Graham Primm · · 2026-07-08 16:50 UTC
- committer
- GitHub · · 2026-07-08 16:50 UTC
- parent
- 30f9ca5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.2.67
3 files · +112 -6
M README.md +14 -0
| @@ -619,6 +619,20 @@ ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat" | |||
| 619 | 619 | ||
| 620 | 620 | Either way, theme colors still drive the rendering (the header uses your accent/foreground, output uses the tool-output color) and `ctrl+o` still expands a truncated result. | |
| 621 | 621 | ||
| 622 | + | ### Tool arg width | |
| 623 | + | ||
| 624 | + | The header line for a tool call shows the tool name plus a one-line summary of its primary argument — a `path`, a `command`, or a query. That summary is truncated to 60 cells by default (`web_answer What is the best architecture to implement resilience wit...`). On a wide terminal that can clip long queries more than you'd like, so set the `ZOT_TOOL_ARG_WIDTH` env var to raise (or lower) the limit: | |
| 625 | + | ||
| 626 | + | ```sh | |
| 627 | + | ZOT_TOOL_ARG_WIDTH=120 zot # allow up to 120 cells before truncating | |
| 628 | + | ``` | |
| 629 | + | ||
| 630 | + | | Value | Effect | | |
| 631 | + | |---|---| | |
| 632 | + | | unset (default) | Truncate the arg summary at 60 cells. | | |
| 633 | + | | integer in `[20, 500]` | Truncate at that many cells instead. | | |
| 634 | + | | anything else | Ignored; falls back to the 60-cell default. | | |
| 635 | + | ||
| 622 | 636 | ## Compact input | |
| 623 | 637 | ||
| 624 | 638 | By default a message you send renders as a padded, background-tinted bubble: a blank tinted row above and below the text, with a `▌` accent bar down the left. So even a one-line prompt occupies three rows. Set `compact_input` to collapse it to a single quiet `▌ your text` gutter line per wrapped row — no padding rows, no background tint. | |
| @@ -619,6 +619,20 @@ ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat" | |||||
| 619 | 619 | ||||
| 620 | Either way, theme colors still drive the rendering (the header uses your accent/foreground, output uses the tool-output color) and `ctrl+o` still expands a truncated result. | 620 | Either way, theme colors still drive the rendering (the header uses your accent/foreground, output uses the tool-output color) and `ctrl+o` still expands a truncated result. | ||
| 621 | 621 | ||||
| 622 | + | ### Tool arg width | |||
| 623 | + | ||||
| 624 | + | The header line for a tool call shows the tool name plus a one-line summary of its primary argument — a `path`, a `command`, or a query. That summary is truncated to 60 cells by default (`web_answer What is the best architecture to implement resilience wit...`). On a wide terminal that can clip long queries more than you'd like, so set the `ZOT_TOOL_ARG_WIDTH` env var to raise (or lower) the limit: | |||
| 625 | + | ||||
| 626 | + | ```sh | |||
| 627 | + | ZOT_TOOL_ARG_WIDTH=120 zot # allow up to 120 cells before truncating | |||
| 628 | + | ``` | |||
| 629 | + | ||||
| 630 | + | | Value | Effect | | |||
| 631 | + | |---|---| | |||
| 632 | + | | unset (default) | Truncate the arg summary at 60 cells. | | |||
| 633 | + | | integer in `[20, 500]` | Truncate at that many cells instead. | | |||
| 634 | + | | anything else | Ignored; falls back to the 60-cell default. | | |||
| 635 | + | ||||
| 622 | ## Compact input | 636 | ## Compact input | ||
| 623 | 637 | ||||
| 624 | By default a message you send renders as a padded, background-tinted bubble: a blank tinted row above and below the text, with a `▌` accent bar down the left. So even a one-line prompt occupies three rows. Set `compact_input` to collapse it to a single quiet `▌ your text` gutter line per wrapped row — no padding rows, no background tint. | 638 | By default a message you send renders as a padded, background-tinted bubble: a blank tinted row above and below the text, with a `▌` accent bar down the left. So even a one-line prompt occupies three rows. Set `compact_input` to collapse it to a single quiet `▌ your text` gutter line per wrapped row — no padding rows, no background tint. | ||
M packages/tui/view.go +35 -6
| @@ -2131,19 +2131,48 @@ func toolResultBlock(th Theme, text string, width int, color int) []string { | |||
| 2131 | 2131 | return out | |
| 2132 | 2132 | } | |
| 2133 | 2133 | ||
| 2134 | + | // defaultToolArgWidth is the number of cells the tool-call header | |
| 2135 | + | // truncates the primary argument (path/command/query) to when the | |
| 2136 | + | // ZOT_TOOL_ARG_WIDTH environment variable is unset or invalid. | |
| 2137 | + | const defaultToolArgWidth = 60 | |
| 2138 | + | ||
| 2139 | + | // toolArgWidth returns the maximum cell width for the truncated | |
| 2140 | + | // primary argument shown in a tool-call header. It defaults to | |
| 2141 | + | // defaultToolArgWidth but can be raised or lowered with the | |
| 2142 | + | // ZOT_TOOL_ARG_WIDTH environment variable, which is useful for wide | |
| 2143 | + | // terminals where the default clips long queries too aggressively. | |
| 2144 | + | // Values outside the sane range [20, 500] are ignored so a stray or | |
| 2145 | + | // malformed setting can never produce an unreadable header or an | |
| 2146 | + | // out-of-range slice. | |
| 2147 | + | func toolArgWidth() int { | |
| 2148 | + | v := strings.TrimSpace(os.Getenv("ZOT_TOOL_ARG_WIDTH")) | |
| 2149 | + | if v == "" { | |
| 2150 | + | return defaultToolArgWidth | |
| 2151 | + | } | |
| 2152 | + | n, err := strconv.Atoi(v) | |
| 2153 | + | if err != nil || n < 20 || n > 500 { | |
| 2154 | + | return defaultToolArgWidth | |
| 2155 | + | } | |
| 2156 | + | return n | |
| 2157 | + | } | |
| 2158 | + | ||
| 2134 | 2159 | // ShortArgs renders a tool call's arguments into a one-line | |
| 2135 | 2160 | // suffix for the "tool name <args>" header. tool is the tool | |
| 2136 | 2161 | // name so we can add shape-specific decorations: for read we | |
| 2137 | 2162 | // append the requested line range (e.g. "path:1-200") pulled | |
| 2138 | 2163 | // from the offset/limit args, which is useful context at a | |
| 2139 | 2164 | // glance without expanding the result body. Other tools keep | |
| 2140 | - | // the legacy "path or command, truncated at 60 cells" shape. | |
| 2165 | + | // the legacy "path or command, truncated" shape. | |
| 2166 | + | // | |
| 2167 | + | // The truncation width defaults to 60 cells but can be tuned via | |
| 2168 | + | // the ZOT_TOOL_ARG_WIDTH environment variable (see toolArgWidth). | |
| 2141 | 2169 | // | |
| 2142 | 2170 | // Exported because the interactive mode pre-populates the | |
| 2143 | 2171 | // ToolCallView.Args field with this value as soon as the tool | |
| 2144 | 2172 | // call is announced, so the live overlay's header matches what | |
| 2145 | 2173 | // the finalised transcript will later render. | |
| 2146 | 2174 | func ShortArgs(tool string, raw json.RawMessage) string { | |
| 2175 | + | width := toolArgWidth() | |
| 2147 | 2176 | var v any | |
| 2148 | 2177 | if err := json.Unmarshal(raw, &v); err != nil { | |
| 2149 | 2178 | return "" | |
| @@ -2152,8 +2181,8 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2152 | 2181 | if !ok { | |
| 2153 | 2182 | b, _ := json.Marshal(v) | |
| 2154 | 2183 | s := oneLineToolLabel(string(b)) | |
| 2155 | - | if len(s) > 60 { | |
| 2156 | - | s = s[:57] + "..." | |
| 2184 | + | if len(s) > width { | |
| 2185 | + | s = s[:width-3] + "..." | |
| 2157 | 2186 | } | |
| 2158 | 2187 | return s | |
| 2159 | 2188 | } | |
| @@ -2167,8 +2196,8 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2167 | 2196 | if primary == "" { | |
| 2168 | 2197 | b, _ := json.Marshal(v) | |
| 2169 | 2198 | s := oneLineToolLabel(string(b)) | |
| 2170 | - | if len(s) > 60 { | |
| 2171 | - | s = s[:57] + "..." | |
| 2199 | + | if len(s) > width { | |
| 2200 | + | s = s[:width-3] + "..." | |
| 2172 | 2201 | } | |
| 2173 | 2202 | return s | |
| 2174 | 2203 | } | |
| @@ -2193,7 +2222,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2193 | 2222 | ||
| 2194 | 2223 | // Truncate the primary arg leaving room for the suffix so the | |
| 2195 | 2224 | // range stays visible even on absurdly long paths. | |
| 2196 | - | max := 60 - len(suffix) | |
| 2225 | + | max := width - len(suffix) | |
| 2197 | 2226 | if max < 10 { | |
| 2198 | 2227 | max = 10 | |
| 2199 | 2228 | } | |
| @@ -2131,19 +2131,48 @@ func toolResultBlock(th Theme, text string, width int, color int) []string { | |||||
| 2131 | return out | 2131 | return out | ||
| 2132 | } | 2132 | } | ||
| 2133 | 2133 | ||||
| 2134 | + | // defaultToolArgWidth is the number of cells the tool-call header | |||
| 2135 | + | // truncates the primary argument (path/command/query) to when the | |||
| 2136 | + | // ZOT_TOOL_ARG_WIDTH environment variable is unset or invalid. | |||
| 2137 | + | const defaultToolArgWidth = 60 | |||
| 2138 | + | ||||
| 2139 | + | // toolArgWidth returns the maximum cell width for the truncated | |||
| 2140 | + | // primary argument shown in a tool-call header. It defaults to | |||
| 2141 | + | // defaultToolArgWidth but can be raised or lowered with the | |||
| 2142 | + | // ZOT_TOOL_ARG_WIDTH environment variable, which is useful for wide | |||
| 2143 | + | // terminals where the default clips long queries too aggressively. | |||
| 2144 | + | // Values outside the sane range [20, 500] are ignored so a stray or | |||
| 2145 | + | // malformed setting can never produce an unreadable header or an | |||
| 2146 | + | // out-of-range slice. | |||
| 2147 | + | func toolArgWidth() int { | |||
| 2148 | + | v := strings.TrimSpace(os.Getenv("ZOT_TOOL_ARG_WIDTH")) | |||
| 2149 | + | if v == "" { | |||
| 2150 | + | return defaultToolArgWidth | |||
| 2151 | + | } | |||
| 2152 | + | n, err := strconv.Atoi(v) | |||
| 2153 | + | if err != nil || n < 20 || n > 500 { | |||
| 2154 | + | return defaultToolArgWidth | |||
| 2155 | + | } | |||
| 2156 | + | return n | |||
| 2157 | + | } | |||
| 2158 | + | ||||
| 2134 | // ShortArgs renders a tool call's arguments into a one-line | 2159 | // ShortArgs renders a tool call's arguments into a one-line | ||
| 2135 | // suffix for the "tool name <args>" header. tool is the tool | 2160 | // suffix for the "tool name <args>" header. tool is the tool | ||
| 2136 | // name so we can add shape-specific decorations: for read we | 2161 | // name so we can add shape-specific decorations: for read we | ||
| 2137 | // append the requested line range (e.g. "path:1-200") pulled | 2162 | // append the requested line range (e.g. "path:1-200") pulled | ||
| 2138 | // from the offset/limit args, which is useful context at a | 2163 | // from the offset/limit args, which is useful context at a | ||
| 2139 | // glance without expanding the result body. Other tools keep | 2164 | // glance without expanding the result body. Other tools keep | ||
| 2140 | - | // the legacy "path or command, truncated at 60 cells" shape. | 2165 | + | // the legacy "path or command, truncated" shape. |
| 2166 | + | // | |||
| 2167 | + | // The truncation width defaults to 60 cells but can be tuned via | |||
| 2168 | + | // the ZOT_TOOL_ARG_WIDTH environment variable (see toolArgWidth). | |||
| 2141 | // | 2169 | // | ||
| 2142 | // Exported because the interactive mode pre-populates the | 2170 | // Exported because the interactive mode pre-populates the | ||
| 2143 | // ToolCallView.Args field with this value as soon as the tool | 2171 | // ToolCallView.Args field with this value as soon as the tool | ||
| 2144 | // call is announced, so the live overlay's header matches what | 2172 | // call is announced, so the live overlay's header matches what | ||
| 2145 | // the finalised transcript will later render. | 2173 | // the finalised transcript will later render. | ||
| 2146 | func ShortArgs(tool string, raw json.RawMessage) string { | 2174 | func ShortArgs(tool string, raw json.RawMessage) string { | ||
| 2175 | + | width := toolArgWidth() | |||
| 2147 | var v any | 2176 | var v any | ||
| 2148 | if err := json.Unmarshal(raw, &v); err != nil { | 2177 | if err := json.Unmarshal(raw, &v); err != nil { | ||
| 2149 | return "" | 2178 | return "" | ||
| @@ -2152,8 +2181,8 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2152 | if !ok { | 2181 | if !ok { | ||
| 2153 | b, _ := json.Marshal(v) | 2182 | b, _ := json.Marshal(v) | ||
| 2154 | s := oneLineToolLabel(string(b)) | 2183 | s := oneLineToolLabel(string(b)) | ||
| 2155 | - | if len(s) > 60 { | 2184 | + | if len(s) > width { |
| 2156 | - | s = s[:57] + "..." | 2185 | + | s = s[:width-3] + "..." |
| 2157 | } | 2186 | } | ||
| 2158 | return s | 2187 | return s | ||
| 2159 | } | 2188 | } | ||
| @@ -2167,8 +2196,8 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2167 | if primary == "" { | 2196 | if primary == "" { | ||
| 2168 | b, _ := json.Marshal(v) | 2197 | b, _ := json.Marshal(v) | ||
| 2169 | s := oneLineToolLabel(string(b)) | 2198 | s := oneLineToolLabel(string(b)) | ||
| 2170 | - | if len(s) > 60 { | 2199 | + | if len(s) > width { |
| 2171 | - | s = s[:57] + "..." | 2200 | + | s = s[:width-3] + "..." |
| 2172 | } | 2201 | } | ||
| 2173 | return s | 2202 | return s | ||
| 2174 | } | 2203 | } | ||
| @@ -2193,7 +2222,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 2193 | 2222 | ||||
| 2194 | // Truncate the primary arg leaving room for the suffix so the | 2223 | // Truncate the primary arg leaving room for the suffix so the | ||
| 2195 | // range stays visible even on absurdly long paths. | 2224 | // range stays visible even on absurdly long paths. | ||
| 2196 | - | max := 60 - len(suffix) | 2225 | + | max := width - len(suffix) |
| 2197 | if max < 10 { | 2226 | if max < 10 { | ||
| 2198 | max = 10 | 2227 | max = 10 | ||
| 2199 | } | 2228 | } | ||
A packages/tui/view_tool_arg_width_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestToolArgWidthDefault(t *testing.T) { | |
| 10 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "") | |
| 11 | + | if got := toolArgWidth(); got != defaultToolArgWidth { | |
| 12 | + | t.Fatalf("toolArgWidth() with unset env = %d, want %d", got, defaultToolArgWidth) | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestToolArgWidthEnvOverride(t *testing.T) { | |
| 17 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |
| 18 | + | if got := toolArgWidth(); got != 120 { | |
| 19 | + | t.Fatalf("toolArgWidth() = %d, want 120", got) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestToolArgWidthIgnoresInvalid(t *testing.T) { | |
| 24 | + | cases := []string{"nope", "0", "10", "501", "-5", "12.5"} | |
| 25 | + | for _, c := range cases { | |
| 26 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", c) | |
| 27 | + | if got := toolArgWidth(); got != defaultToolArgWidth { | |
| 28 | + | t.Fatalf("toolArgWidth() with %q = %d, want default %d", c, got, defaultToolArgWidth) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestShortArgsTruncatesAtDefaultWidth(t *testing.T) { | |
| 34 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "") | |
| 35 | + | long := strings.Repeat("a", 200) | |
| 36 | + | raw := json.RawMessage(`{"command":"` + long + `"}`) | |
| 37 | + | got := ShortArgs("web_answer", raw) | |
| 38 | + | if len(got) != defaultToolArgWidth { | |
| 39 | + | t.Fatalf("ShortArgs length = %d, want %d (%q)", len(got), defaultToolArgWidth, got) | |
| 40 | + | } | |
| 41 | + | if !strings.HasSuffix(got, "...") { | |
| 42 | + | t.Fatalf("ShortArgs should end with ellipsis, got %q", got) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestShortArgsRespectsWiderWidth(t *testing.T) { | |
| 47 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |
| 48 | + | long := strings.Repeat("a", 200) | |
| 49 | + | raw := json.RawMessage(`{"command":"` + long + `"}`) | |
| 50 | + | got := ShortArgs("web_answer", raw) | |
| 51 | + | if len(got) != 120 { | |
| 52 | + | t.Fatalf("ShortArgs length = %d, want 120", len(got)) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestShortArgsNoTruncationWhenShort(t *testing.T) { | |
| 57 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |
| 58 | + | raw := json.RawMessage(`{"command":"short query"}`) | |
| 59 | + | got := ShortArgs("web_answer", raw) | |
| 60 | + | if got != "short query" { | |
| 61 | + | t.Fatalf("ShortArgs = %q, want %q", got, "short query") | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestToolArgWidthDefault(t *testing.T) { | |||
| 10 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "") | |||
| 11 | + | if got := toolArgWidth(); got != defaultToolArgWidth { | |||
| 12 | + | t.Fatalf("toolArgWidth() with unset env = %d, want %d", got, defaultToolArgWidth) | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestToolArgWidthEnvOverride(t *testing.T) { | |||
| 17 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |||
| 18 | + | if got := toolArgWidth(); got != 120 { | |||
| 19 | + | t.Fatalf("toolArgWidth() = %d, want 120", got) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestToolArgWidthIgnoresInvalid(t *testing.T) { | |||
| 24 | + | cases := []string{"nope", "0", "10", "501", "-5", "12.5"} | |||
| 25 | + | for _, c := range cases { | |||
| 26 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", c) | |||
| 27 | + | if got := toolArgWidth(); got != defaultToolArgWidth { | |||
| 28 | + | t.Fatalf("toolArgWidth() with %q = %d, want default %d", c, got, defaultToolArgWidth) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestShortArgsTruncatesAtDefaultWidth(t *testing.T) { | |||
| 34 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "") | |||
| 35 | + | long := strings.Repeat("a", 200) | |||
| 36 | + | raw := json.RawMessage(`{"command":"` + long + `"}`) | |||
| 37 | + | got := ShortArgs("web_answer", raw) | |||
| 38 | + | if len(got) != defaultToolArgWidth { | |||
| 39 | + | t.Fatalf("ShortArgs length = %d, want %d (%q)", len(got), defaultToolArgWidth, got) | |||
| 40 | + | } | |||
| 41 | + | if !strings.HasSuffix(got, "...") { | |||
| 42 | + | t.Fatalf("ShortArgs should end with ellipsis, got %q", got) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestShortArgsRespectsWiderWidth(t *testing.T) { | |||
| 47 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |||
| 48 | + | long := strings.Repeat("a", 200) | |||
| 49 | + | raw := json.RawMessage(`{"command":"` + long + `"}`) | |||
| 50 | + | got := ShortArgs("web_answer", raw) | |||
| 51 | + | if len(got) != 120 { | |||
| 52 | + | t.Fatalf("ShortArgs length = %d, want 120", len(got)) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestShortArgsNoTruncationWhenShort(t *testing.T) { | |||
| 57 | + | t.Setenv("ZOT_TOOL_ARG_WIDTH", "120") | |||
| 58 | + | raw := json.RawMessage(`{"command":"short query"}`) | |||
| 59 | + | got := ShortArgs("web_answer", raw) | |||
| 60 | + | if got != "short query" { | |||
| 61 | + | t.Fatalf("ShortArgs = %q, want %q", got, "short query") | |||
| 62 | + | } | |||
| 63 | + | } | |||