feat(tui): add flat (boxless) tool-render mode

Tool calls render inside a bordered panel by default. On screens with
many calls the borders read as busy, so add an opt-in flat mode: a
quiet header line per call plus indented, border-free output. Tool
name, arg summary, streamed live output, theme colors, and the
ctrl+o truncation/expand behaviour are all preserved.

Gated behind a non-breaking toggle (default stays box):

  - config.json "tool_render": "box" | "flat"
  - ZOT_FLAT_TOOLS env var overrides the config for a single run

Purely a TUI-layer change; core and agent are untouched.
This commit is contained in:
kekkker 2026-06-26 11:19:01 +03:00
parent b325477870
commit cf4ee29d79
No known key found for this signature in database
GPG key ID: 89ACE636A8263BA0
6 changed files with 309 additions and 2 deletions

View file

@ -555,6 +555,38 @@ When a tool returns an image (for example `read` on a PNG), zot renders it inlin
Frames containing images are full-repainted (no differential diff) to prevent stale image pixels from lingering through scroll. That costs one terminal flash per image-containing frame; set `ZOT_INLINE_IMAGES=off` if that bothers you.
## Tool rendering
By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zot also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
Set the `tool_render` key in `$ZOT_HOME/config.json`:
```json
{
"tool_render": "flat"
}
```
| Value | Effect |
|---|---|
| unset / `"box"` (default) | Each tool call is wrapped in a bordered panel. |
| `"flat"` | Boxless: a quiet header line plus indented output. |
The `ZOT_FLAT_TOOLS` env var overrides the config for a single run, which is handy for trying it without editing the file:
| Value | Effect |
|---|---|
| `1`, `true`, `yes`, `on`, `flat` | Force flat rendering. |
| `0`, `false`, `no`, `off`, `box` | Force the bordered panel. |
| unset | Fall back to the `tool_render` config key. |
```sh
ZOT_FLAT_TOOLS=1 zot # flat, just this run
ZOT_FLAT_TOOLS=0 zot # boxes, even if config.json says "flat"
```
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.
## Queued messages
You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot.

View file

@ -966,6 +966,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
RespectGitignore: initialCfg.RespectGitignore,
ThemeName: initialCfg.Theme,
FlatTools: initialCfg.FlatToolRender(),
ExtensionThemes: extMgr.ThemeOptions,
AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
SettingsStore: configSettingsStore{},

View file

@ -29,6 +29,13 @@ type Config struct {
Temperature *float32 `json:"temperature,omitempty"`
Theme string `json:"theme"`
// ToolRender selects how tool calls are drawn in interactive mode.
// "box" (default, or empty) wraps each call in a bordered panel;
// "flat" drops the frame for a quiet header line plus indented,
// frameless output. The ZOT_FLAT_TOOLS env var overrides this when
// set ("1"/"true" forces flat, "0"/"false" forces box).
ToolRender string `json:"tool_render,omitempty"`
// QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
// Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
@ -97,6 +104,23 @@ func ZotHome() string {
// ConfigPath returns the path to config.json.
func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
// FlatToolRender reports whether tool calls should render flat (no
// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over
// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
// "no"/"off" force box. Otherwise the config's tool_render is
// consulted; "flat" is flat, anything else (including empty) is box.
func (c Config) FlatToolRender() bool {
if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" {
switch v {
case "1", "true", "yes", "on", "flat":
return true
case "0", "false", "no", "off", "box":
return false
}
}
return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
}
// AuthPath returns the path to auth.json.
func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }

View file

@ -61,6 +61,11 @@ type InteractiveConfig struct {
// ThemeName mirrors the persisted config theme value. Empty means auto.
ThemeName string
// FlatTools renders tool calls without the bordered panel (a quiet
// header line plus indented, frameless output). Mirrors the
// resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
FlatTools bool
// QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
// shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
// forwards Command/Super keypresses, but Ctrl is the displayed chord.
@ -475,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
view: &tui.View{
Theme: cfg.Theme,
ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
FlatTools: cfg.FlatTools,
},
// Prompt is the standard half-block accent bar used by chat
// speaker labels too, so the input gutter matches the rest

View file

@ -132,6 +132,12 @@ type View struct {
// the map when a turn ends.
liveBodyHigh map[string]int
// FlatTools renders tool calls without the bordered panel: a quiet
// header line per call plus indented, frameless output. The
// truncation/expand behaviour and theme colors are unchanged.
// False (the default) keeps the bordered box.
FlatTools bool
// ExpandAll forces every long tool result to render in full.
// Toggled from the tui by ctrl+o. When false, results longer than
// ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@ -706,6 +712,17 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
// from the matching ToolCallBlock so multiple calls
// in one assistant message render as N adjacent boxes
// instead of stacking unclosed top edges.
if v.FlatTools {
lines = append(lines, flatToolHeader(v.Theme, label, width))
if tr.IsError {
lines = append(lines, flatToolBody(v.Theme, v.Theme.FG256(color, " error")))
}
for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
_, stripped := parseImageFootprint(line)
lines = append(lines, flatToolBody(v.Theme, stripped))
}
continue
}
lines = append(lines, toolBoxTop(v.Theme, label, width))
lines = append(lines, toolBoxSide(v.Theme, "", width))
if tr.IsError {
@ -774,6 +791,14 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
}
v.liveBodyHigh[tc.ID] = high
}
if v.FlatTools {
for len(body) < high {
body = append(body, "")
}
lines = append(lines, flatToolHeader(v.Theme, label, width))
lines = append(lines, body...)
return lines
}
for len(body) < high {
body = append(body, toolBoxSide(v.Theme, "", width))
}
@ -790,6 +815,10 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
// directly closed by the bottom. Avoids a blank interior row
// for no-output tools.
if tc.Result == "" {
if v.FlatTools {
lines = append(lines, flatToolHeader(v.Theme, label, width))
return lines
}
lines = append(lines, toolBoxTop(v.Theme, label, width))
lines = append(lines, toolBoxBottom(v.Theme, width))
return lines
@ -800,12 +829,21 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
// vertical edges; bottom edge closes the box. Blank interior
// rows after the top and before the bottom give the body a bit
// of breathing room from the corners.
lines = append(lines, toolBoxTop(v.Theme, label, width))
lines = append(lines, toolBoxSide(v.Theme, "", width))
color := v.Theme.ToolOut
if tc.Error {
color = v.Theme.Error
}
if v.FlatTools {
lines = append(lines, flatToolHeader(v.Theme, label, width))
body := toolResultBlock(v.Theme, tc.Result, flatToolBodyRenderWidth(width), color)
for _, l := range v.collapseToolBody(body, false) {
_, stripped := parseImageFootprint(l)
lines = append(lines, flatToolBody(v.Theme, stripped))
}
return lines
}
lines = append(lines, toolBoxTop(v.Theme, label, width))
lines = append(lines, toolBoxSide(v.Theme, "", width))
body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
for _, l := range v.collapseToolBody(body, false) {
imgCells, stripped := parseImageFootprint(l)
@ -863,6 +901,9 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
func (v *View) renderLiveBashCommand(command string, width int) []string {
inner := toolBoxBodyRenderWidth(width)
if v.FlatTools {
inner = flatToolBodyRenderWidth(width)
}
prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
var out []string
for i, line := range strings.Split(command, "\n") {
@ -896,6 +937,13 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
func (v *View) wrapLiveBody(body []string, width int) []string {
body = v.collapseToolBody(body, false)
out := make([]string, 0, len(body))
if v.FlatTools {
for _, l := range body {
_, stripped := parseImageFootprint(l)
out = append(out, flatToolBody(v.Theme, stripped))
}
return out
}
for _, l := range body {
imgCells, stripped := parseImageFootprint(l)
if hasImageEscapeLine(stripped) {
@ -994,6 +1042,49 @@ func splitToolLabel(label string) (name, rest string) {
return label[:idx], label[idx:]
}
// flatToolHeader renders the boxless header line for a tool call:
//
// ▌ bash xcrun simctl list devices
//
// It carries the same information as toolBoxTop (tool name + short
// args) but drops the frame: a single accent gutter glyph, the tool
// name in the foreground color, and the argument summary muted. The
// line is left-aligned at the same column the box's opening corner
// used (toolBoxOuterMargin) so flat and box renders share a column.
func flatToolHeader(th Theme, label string, width int) string {
label = oneLineToolLabel(label)
name, rest := splitToolLabel(label)
margin := strings.Repeat(" ", toolBoxOuterMargin)
gutter := th.FG256(th.Accent, "▌") + " "
// Budget the visible text to the content width so a very long
// argument summary doesn't run off the right edge; truncate with
// an ellipsis like the box header does.
avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
if avail < 12 {
avail = 12
}
if visibleWidth(name+rest) > avail {
over := visibleWidth(name+rest) - avail
runes := []rune(rest)
if over+3 < len(runes) {
rest = string(runes[:len(runes)-over-3]) + "..."
} else if visibleWidth(name) <= avail {
rest = ""
}
}
return margin + gutter + th.FG256(th.FG, name) + th.FG256(th.Muted, rest)
}
// flatToolBody indents a single body line for boxless rendering. It
// trims the same leading padding box bodies do, then prepends a fixed
// gutter indent so output sits in a readable column under the header
// without any vertical frame. ANSI styling in the line is preserved.
func flatToolBody(th Theme, line string) string {
_ = th
line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
return strings.Repeat(" ", toolBoxOuterMargin+2) + line
}
// toolBoxBottom renders the bottom edge of a tool block:
//
// └─────────────────────────────────────────────────────────────────────────────┘
@ -1080,6 +1171,20 @@ func toolBoxBodyRenderWidth(width int) int {
return inner + toolBoxBodyTrimLeft
}
// flatToolBodyRenderWidth is the width body renderers should target in
// flat mode. There's no box frame, just the flatToolBody indent
// (toolBoxOuterMargin+2 cells), so the renderable width is the
// terminal width minus that indent. Renderers emit a 4-cell indent of
// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
// so add that back like toolBoxBodyRenderWidth does.
func flatToolBodyRenderWidth(width int) int {
inner := width - (toolBoxOuterMargin + 2)
if inner < 1 {
inner = 1
}
return inner + toolBoxBodyTrimLeft
}
// toolBoxSide wraps a single body line with vertical box edges:
//
// │ foo bar baz │
@ -1202,6 +1307,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
switch bb := b.(type) {
case provider.TextBlock:
bodyWidth := toolBoxBodyRenderWidth(width)
if v.FlatTools {
bodyWidth = flatToolBodyRenderWidth(width)
}
body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
case provider.ImageBlock:
hasImage = true

View file

@ -0,0 +1,136 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/patriceckhart/zot/packages/provider"
)
// boxGlyphs are the runes that only appear when a tool call is drawn
// inside the bordered panel. Flat mode must emit none of them.
const boxGlyphs = "┌└│┐┘"
func assertNoBoxGlyphs(t *testing.T, plain string) {
t.Helper()
if strings.ContainsAny(plain, boxGlyphs) {
t.Fatalf("flat render still contains box glyphs:\n%s", plain)
}
}
func TestFlatToolRenderDropsBorders(t *testing.T) {
args := json.RawMessage(`{"command":"echo hi"}`)
v := View{
Theme: Dark,
FlatTools: true,
ToolCalls: []ToolCallView{
{ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
},
}
plain := stripANSI(strings.Join(v.Build(80), "\n"))
assertNoBoxGlyphs(t, plain)
if !strings.Contains(plain, "bash") {
t.Fatalf("flat header lost the tool name:\n%s", plain)
}
if !strings.Contains(plain, "hi") {
t.Fatalf("flat render lost the output:\n%s", plain)
}
}
// The box render of the same call still has borders — the toggle is
// what changes, not the data.
func TestBoxToolRenderKeepsBorders(t *testing.T) {
args := json.RawMessage(`{"command":"echo hi"}`)
v := View{
Theme: Dark,
ToolCalls: []ToolCallView{
{ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "hi\n"},
},
}
plain := stripANSI(strings.Join(v.Build(80), "\n"))
if !strings.ContainsAny(plain, boxGlyphs) {
t.Fatalf("box render should contain border glyphs:\n%s", plain)
}
}
func TestFlatToolRenderLiveBodyHasNoBorders(t *testing.T) {
args := json.RawMessage(`{"command":"printf 'start' && sleep 60"}`)
v := View{
Theme: Dark,
FlatTools: true,
ToolCalls: []ToolCallView{
{ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)},
},
}
plain := stripANSI(strings.Join(v.BuildLive(100), "\n"))
assertNoBoxGlyphs(t, plain)
if !strings.Contains(plain, "$ printf 'start'") {
t.Fatalf("flat live body lost the streamed command:\n%s", plain)
}
}
// Truncation + the ctrl+o expand footer must survive flat mode.
func TestFlatToolRenderKeepsTruncationFooter(t *testing.T) {
var b strings.Builder
for i := 0; i < ToolCollapseLines+50; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
args := json.RawMessage(`{"command":"seq 999"}`)
collapsed := View{
Theme: Dark,
FlatTools: true,
ToolCalls: []ToolCallView{
{ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: b.String()},
},
}
plain := stripANSI(strings.Join(collapsed.Build(80), "\n"))
assertNoBoxGlyphs(t, plain)
if !strings.Contains(plain, "ctrl+o to expand") {
t.Fatalf("flat render dropped the truncation footer:\n%s", plain)
}
// ExpandAll shows everything and still has no borders.
collapsed.ExpandAll = true
full := stripANSI(strings.Join(collapsed.Build(80), "\n"))
assertNoBoxGlyphs(t, full)
if strings.Contains(full, "ctrl+o to expand") {
t.Fatalf("expanded flat render should not show the footer:\n%s", full)
}
if !strings.Contains(full, fmt.Sprintf("line %d", ToolCollapseLines+40)) {
t.Fatalf("expanded flat render is missing later lines:\n%s", full)
}
}
// A finished tool result that comes back as a RoleTool message (the
// transcript path, distinct from the in-flight ToolCalls path) also
// renders flat.
func TestFlatToolRenderTranscriptResult(t *testing.T) {
callArgs := json.RawMessage(`{"command":"echo done"}`)
v := View{
Theme: Dark,
FlatTools: true,
Messages: []provider.Message{
{
Role: provider.RoleAssistant,
Content: []provider.Content{
provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: callArgs},
},
},
{
Role: provider.RoleTool,
Content: []provider.Content{
provider.ToolResultBlock{CallID: "toolu_1", Content: []provider.Content{
provider.TextBlock{Text: "done"},
}},
},
},
},
}
plain := stripANSI(strings.Join(v.Build(80), "\n"))
assertNoBoxGlyphs(t, plain)
if !strings.Contains(plain, "bash") || !strings.Contains(plain, "done") {
t.Fatalf("flat transcript result lost header or output:\n%s", plain)
}
}