mirror of
https://github.com/patriceckhart/zot.git
synced 2026-07-02 08:09:53 +02:00
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:
parent
b325477870
commit
cf4ee29d79
6 changed files with 309 additions and 2 deletions
32
README.md
32
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
packages/tui/view_flat_tools_test.go
Normal file
136
packages/tui/view_flat_tools_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue