From 9484dbcb8a1f3ee57ae10891028622b7f973416f Mon Sep 17 00:00:00 2001 From: kekkker <95563882+kekkker@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:33:14 +0300 Subject: [PATCH] feat(tui): add compact user-input rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 21 +++++++ packages/agent/cli.go | 1 + packages/agent/config.go | 24 ++++++++ packages/agent/modes/interactive.go | 12 +++- packages/tui/view.go | 31 +++++++++- packages/tui/view_compact_user_test.go | 81 ++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 packages/tui/view_compact_user_test.go diff --git a/README.md b/README.md index 7cd17b5..e95fb84 100644 --- a/README.md +++ b/README.md @@ -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: ` 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. diff --git a/packages/agent/cli.go b/packages/agent/cli.go index 9af212d..0f3fb89 100644 --- a/packages/agent/cli.go +++ b/packages/agent/cli.go @@ -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{}, diff --git a/packages/agent/config.go b/packages/agent/config.go index 96382a3..e6a4936 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -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") } diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index 03c8751..ccf2f41 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -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 diff --git a/packages/tui/view.go b/packages/tui/view.go index 740b278..6345c78 100644 --- a/packages/tui/view.go +++ b/packages/tui/view.go @@ -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 diff --git a/packages/tui/view_compact_user_test.go b/packages/tui/view_compact_user_test.go new file mode 100644 index 0000000..f8732aa --- /dev/null +++ b/packages/tui/view_compact_user_test.go @@ -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) + } + } +}