feat(tui): add compact user-input rendering

Sent user messages render as a padded, background-tinted bubble by
default — a blank tinted row above and below the text plus a left
accent bar — so even a one-line prompt occupies three rows. Add an
opt-in compact mode that collapses each turn to a single quiet
gutter line per wrapped row: no padding rows, no background tint.

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

  - config.json "compact_input": true
  - ZOT_COMPACT_INPUT env var overrides the config for a single run

Sibling to the flat tool-render toggle; same TUI-layer-only scope.
This commit is contained in:
kekkker 2026-06-26 11:33:14 +03:00
parent cf4ee29d79
commit 9484dbcb8a
No known key found for this signature in database
GPG key ID: 89ACE636A8263BA0
6 changed files with 164 additions and 6 deletions

View file

@ -587,6 +587,27 @@ 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.
## Compact input
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.
```json
{
"compact_input": true
}
```
| Value | Effect |
|---|---|
| unset / `false` (default) | Padded, background-tinted user bubble. |
| `true` | One quiet gutter line per wrapped row. |
The `ZOT_COMPACT_INPUT` env var overrides the config for a single run (`1`/`true`/`on`/`compact` force compact; `0`/`false`/`off`/`bubble` force the bubble):
```sh
ZOT_COMPACT_INPUT=1 zot # compact, just this run
```
## 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

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

View file

@ -36,6 +36,12 @@ type Config struct {
// set ("1"/"true" forces flat, "0"/"false" forces box).
ToolRender string `json:"tool_render,omitempty"`
// CompactInput renders sent user messages as a single quiet gutter
// line instead of a padded, background-tinted bubble. nil/false
// (the default) keeps the bubble. The ZOT_COMPACT_INPUT env var
// overrides this when set.
CompactInput *bool `json:"compact_input,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"`
@ -121,6 +127,24 @@ func (c Config) FlatToolRender() bool {
return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
}
// CompactUserInput reports whether sent user messages should render as
// a single quiet gutter line instead of a padded, background-tinted
// bubble. The ZOT_COMPACT_INPUT env var takes precedence over the
// config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/
// "no"/"off" force the bubble. Otherwise the config's compact_input
// is consulted (nil/false means the bubble).
func (c Config) CompactUserInput() bool {
if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_COMPACT_INPUT"))); v != "" {
switch v {
case "1", "true", "yes", "on", "compact":
return true
case "0", "false", "no", "off", "bubble":
return false
}
}
return c.CompactInput != nil && *c.CompactInput
}
// AuthPath returns the path to auth.json.
func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }

View file

@ -66,6 +66,11 @@ type InteractiveConfig struct {
// resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
FlatTools bool
// CompactUser renders sent user messages as a single quiet gutter
// line instead of a padded, tinted bubble. Mirrors the resolved
// compact_input config / ZOT_COMPACT_INPUT env at startup.
CompactUser 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.
@ -478,9 +483,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
i := &Interactive{
cfg: cfg,
view: &tui.View{
Theme: cfg.Theme,
ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
FlatTools: cfg.FlatTools,
Theme: cfg.Theme,
ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
FlatTools: cfg.FlatTools,
CompactUser: cfg.CompactUser,
},
// Prompt is the standard half-block accent bar used by chat
// speaker labels too, so the input gutter matches the rest

View file

@ -138,6 +138,12 @@ type View struct {
// False (the default) keeps the bordered box.
FlatTools bool
// CompactUser renders sent user messages as a single quiet gutter
// line per wrapped row instead of a tinted bubble with a blank
// padding row above and below. False (the default) keeps the
// padded, background-tinted bubble.
CompactUser 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
@ -641,6 +647,19 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
return bar + padded
}
if v.CompactUser {
// Compact: no tinted bubble background and no padding rows.
// Each wrapped row is a quiet "▌ text" gutter line, matching
// the flat tool-call header so user turns still segment the
// chat without the loud panel.
innerWidth = width - 2
if innerWidth < 1 {
innerWidth = 1
}
row = func(content string) string {
return v.Theme.FG256(v.Theme.Accent, "▌ ") + content
}
}
var bubble []string
for _, c := range m.Content {
switch b := c.(type) {
@ -656,9 +675,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
}
}
if len(bubble) > 0 {
lines = append(lines, row(""))
lines = append(lines, bubble...)
lines = append(lines, row(""))
if v.CompactUser {
// No tinted padding rows in compact mode; the inter-message
// blank from Build() already gives the turn breathing room.
lines = append(lines, bubble...)
} else {
lines = append(lines, row(""))
lines = append(lines, bubble...)
lines = append(lines, row(""))
}
}
case provider.RoleAssistant:
// Assistant rows: no speaker label either. Prose still gets a

View file

@ -0,0 +1,81 @@
package tui
import (
"strings"
"testing"
"github.com/patriceckhart/zot/packages/provider"
)
func userMsg(text string) provider.Message {
return provider.Message{
Role: provider.RoleUser,
Content: []provider.Content{provider.TextBlock{Text: text}},
}
}
// userBubbleRows counts the rows that carry the user gutter bar "▌".
func userBubbleRows(plain string) int {
n := 0
for _, l := range strings.Split(plain, "\n") {
if strings.Contains(l, "▌") {
n++
}
}
return n
}
// A one-line prompt is a 3-row bubble by default (blank pad, text,
// blank pad); compact mode collapses it to a single text row.
func TestCompactUserDropsPaddingRows(t *testing.T) {
bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hello")}}
bubblePlain := stripANSI(strings.Join(bubble.Build(80), "\n"))
if got := userBubbleRows(bubblePlain); got != 3 {
t.Fatalf("default bubble should be 3 gutter rows (pad+text+pad), got %d:\n%s", got, bubblePlain)
}
compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hello")}}
compactPlain := stripANSI(strings.Join(compact.Build(80), "\n"))
if got := userBubbleRows(compactPlain); got != 1 {
t.Fatalf("compact user should be a single gutter row, got %d:\n%s", got, compactPlain)
}
if !strings.Contains(compactPlain, "▌ hello") {
t.Fatalf("compact user lost the gutter or text:\n%s", compactPlain)
}
}
// Compact mode must never paint the bubble background tint. The tint
// is an SGR background sequence (ESC[48;...m); the compact path uses
// only a foreground-colored gutter, so no 48; should appear on the
// user rows.
func TestCompactUserHasNoBackgroundTint(t *testing.T) {
compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg("hi there")}}
raw := strings.Join(compact.Build(80), "\n")
if strings.Contains(raw, "[48;") {
t.Fatalf("compact user should not paint a background tint:\n%q", raw)
}
// The default bubble does tint, so this is a meaningful assertion.
bubble := View{Theme: Dark, Messages: []provider.Message{userMsg("hi there")}}
if rawB := strings.Join(bubble.Build(80), "\n"); !strings.Contains(rawB, "[48;") {
t.Fatalf("default bubble was expected to paint a background tint:\n%q", rawB)
}
}
// Multi-line prompts keep every wrapped row in compact mode, just
// without the surrounding padding rows.
func TestCompactUserKeepsAllWrappedRows(t *testing.T) {
long := strings.Repeat("word ", 60) // forces several wrapped rows
compact := View{Theme: Dark, CompactUser: true, Messages: []provider.Message{userMsg(long)}}
plain := stripANSI(strings.Join(compact.Build(40), "\n"))
rows := userBubbleRows(plain)
if rows < 3 {
t.Fatalf("expected multiple wrapped gutter rows, got %d:\n%s", rows, plain)
}
// No empty gutter rows (that would be leftover padding).
for _, l := range strings.Split(plain, "\n") {
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "▌")) == "" && strings.Contains(l, "▌") {
t.Fatalf("compact user emitted an empty padding row:\n%s", plain)
}
}
}